You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@cassandra.apache.org by ma...@apache.org on 2023/07/26 16:27:27 UTC

[cassandra] 03/22: Literal on-disk index and index write path (#9)

This is an automated email from the ASF dual-hosted git repository.

maedhroz pushed a commit to branch cep-7-sai
in repository https://gitbox.apache.org/repos/asf/cassandra.git

commit 9492860a03b9919372bba456a9c7b26f266db4bc
Author: Mike Adamson <mi...@gmail.com>
AuthorDate: Thu Apr 13 17:23:13 2023 +0100

    Literal on-disk index and index write path (#9)
    
    This commit contains the following additions
     to SAI:
     - The index write path and index building
       based around StorageAttachedIndexBuilder
       and StorageAttachedIndexWriter
     - The on-disk index versioning using the
       SSTable Descriptor analog IndexDescriptor
       with Version and OnDiskFormat
     - The literal on-disk index using the
       LiteralIndexWriter
    
    patch by Mike Adamson; reviewed by Caleb Rackliffe and Andres de la Peña for CASSANDRA-18062
    
    Co-authored-by: Mike Adamson <mi...@gmail.com>
    Co-authored-by: Caleb Rackliffe <ca...@gmail.com>
    Co-authored-by: Andres de la Peña <a....@gmail.com>
    Co-authored-by: Piotr Kołaczkowski <pk...@gmail.com>
    Co-authored-by: Jason Rutherglen <ja...@gmail.com>
---
 conf/cassandra.yaml                                |   6 +
 .../config/CassandraRelevantProperties.java        |   2 +
 src/java/org/apache/cassandra/config/Config.java   |   2 +
 .../cassandra/config/DatabaseDescriptor.java       |   7 +
 .../config/StorageAttachedIndexOptions.java        |  44 +
 .../statements/schema/CreateIndexStatement.java    |  80 +-
 src/java/org/apache/cassandra/db/ReadCommand.java  |   1 -
 .../org/apache/cassandra/db/lifecycle/Tracker.java |   6 +
 .../cassandra/db/memtable/AbstractMemtable.java    |  23 +
 .../org/apache/cassandra/db/memtable/Flushing.java |   7 +
 .../org/apache/cassandra/db/memtable/Memtable.java |   5 +
 .../apache/cassandra/index/sai/IndexContext.java   | 306 ++++---
 .../apache/cassandra/index/sai/QueryContext.java   |  11 +-
 .../apache/cassandra/index/sai/SSTableContext.java | 190 +++++
 .../cassandra/index/sai/SSTableContextManager.java | 148 ++++
 .../cassandra/index/sai/StorageAttachedIndex.java  | 241 +++++-
 .../index/sai/StorageAttachedIndexBuilder.java     | 352 ++++++++
 .../index/sai/StorageAttachedIndexGroup.java       | 214 ++++-
 .../index/sai/analyzer/AbstractAnalyzer.java       |   2 -
 .../index/sai/disk/IndexSearchResultIterator.java  | 141 ++++
 .../index/sai/disk/PerColumnIndexWriter.java       |  36 +-
 .../PerSSTableIndexWriter.java}                    |  25 +-
 .../cassandra/index/sai/disk/PrimaryKeyMap.java    |  76 ++
 .../cassandra/index/sai/disk/RowMapping.java       | 175 ++++
 .../cassandra/index/sai/disk/SSTableIndex.java     | 260 ++++++
 .../index/sai/disk/StorageAttachedIndexWriter.java | 258 ++++++
 .../index/sai/disk/format/IndexComponent.java      |  88 ++
 .../index/sai/disk/format/IndexDescriptor.java     | 433 ++++++++++
 .../index/sai/disk/format/OnDiskFormat.java        | 165 ++++
 .../cassandra/index/sai/disk/format/Version.java   | 154 ++++
 .../index/sai/disk/io/IndexFileUtils.java          | 148 ++++
 .../index/sai/disk/io/IndexInputReader.java        | 141 ++++
 .../index/sai/disk/io/IndexOutputWriter.java       | 121 +++
 .../index/sai/disk/io/RAMIndexOutput.java          |  86 ++
 .../sai/disk/io/SeekingRandomAccessInput.java      |  70 ++
 .../cassandra/index/sai/disk/v1/DirectReaders.java | 250 ++++++
 .../cassandra/index/sai/disk/v1/LongArray.java     |  90 ++
 .../index/sai/disk/v1/MemtableIndexWriter.java     | 179 ++++
 .../index/sai/disk/v1/MetadataSource.java          |  96 +++
 .../index/sai/disk/v1/MetadataWriter.java          |  85 ++
 .../index/sai/disk/v1/PerColumnIndexFiles.java     |  81 ++
 .../index/sai/disk/v1/RowAwarePrimaryKeyMap.java   | 198 +++++
 .../cassandra/index/sai/disk/v1/SAICodecUtils.java | 259 ++++++
 .../index/sai/disk/v1/SSTableComponentsWriter.java |  90 ++
 .../index/sai/disk/v1/SSTableIndexWriter.java      | 336 ++++++++
 .../index/sai/disk/v1/V1OnDiskFormat.java          | 247 ++++++
 .../index/sai/disk/v1/V1SSTableIndex.java          | 204 +++++
 .../disk/v1/bitpack/AbstractBlockPackedReader.java |  68 ++
 .../disk/v1/bitpack/AbstractBlockPackedWriter.java | 124 +++
 .../sai/disk/v1/bitpack/BlockPackedReader.java     | 128 +++
 .../sai/disk/v1/bitpack/BlockPackedWriter.java     |  77 ++
 .../v1/bitpack/MonotonicBlockPackedReader.java     | 120 +++
 .../v1/bitpack/MonotonicBlockPackedWriter.java     |  87 ++
 .../v1/bitpack/NumericValuesMeta.java}             |  29 +-
 .../sai/disk/v1/bitpack/NumericValuesWriter.java   | 100 +++
 .../sai/disk/v1/postings/MergePostingList.java     | 130 +++
 .../disk/v1/postings/PostingListRangeIterator.java | 171 ++++
 .../index/sai/disk/v1/postings/PostingsReader.java | 366 ++++++++
 .../index/sai/disk/v1/postings/PostingsWriter.java | 316 +++++++
 .../sai/disk/v1/segment/IndexSegmentSearcher.java  |  93 +++
 .../v1/segment/IndexSegmentSearcherContext.java    |  55 ++
 .../v1/segment/LiteralIndexSegmentSearcher.java    | 109 +++
 .../v1/segment/LiteralIndexSegmentTermsReader.java | 175 ++++
 .../index/sai/disk/v1/segment/Segment.java         | 129 +++
 .../index/sai/disk/v1/segment/SegmentBuilder.java  | 260 ++++++
 .../index/sai/disk/v1/segment/SegmentMetadata.java | 357 ++++++++
 .../sai/disk/v1/sortedterms/SortedTermsMeta.java   |  48 ++
 .../sai/disk/v1/sortedterms/SortedTermsReader.java | 234 ++++++
 .../sai/disk/v1/sortedterms/SortedTermsWriter.java | 214 +++++
 .../sai/disk/v1/sortedterms/package-info.java      |  58 ++
 .../index/sai/disk/v1/trie/LiteralIndexWriter.java | 111 +++
 .../index/sai/disk/v1/trie/TriePrefixSearcher.java |  98 +++
 .../disk/v1/trie/TrieTermsDictionaryReader.java    |  93 +++
 .../disk/v1/trie/TrieTermsDictionaryWriter.java    |  99 +++
 .../KeyRangeConcatIterator.java                    |   8 +-
 .../KeyRangeIntersectionIterator.java              |   6 +-
 .../sai/{utils => iterators}/KeyRangeIterator.java |   3 +-
 .../KeyRangeUnionIterator.java                     |   3 +-
 .../index/sai/memory/ByteSliceReader.java          | 124 +++
 .../index/sai/memory/InMemoryKeyRangeIterator.java |   2 +-
 .../cassandra/index/sai/memory/MemtableIndex.java  |   2 +-
 .../index/sai/memory/MemtableIndexManager.java     | 157 ++++
 .../index/sai/memory/MemtableTermsIterator.java    | 135 +++
 .../index/sai/memory/RAMPostingSlices.java         | 137 +++
 .../index/sai/memory/RAMStringIndexer.java         | 167 ++++
 .../index/sai/memory/TrieMemoryIndex.java          |   2 +-
 .../index/sai/metrics/ColumnQueryMetrics.java      |  91 ++
 ...bleStateMetrics.java => IndexGroupMetrics.java} |  16 +-
 .../cassandra/index/sai/metrics/IndexMetrics.java  |  35 +-
 .../sai/metrics/MulticastQueryEventListeners.java  |  90 ++
 .../index/sai/metrics/QueryEventListener.java      |  75 ++
 .../index/sai/metrics/TableQueryMetrics.java       |  55 +-
 .../index/sai/metrics/TableStateMetrics.java       |  17 +-
 .../cassandra/index/sai/plan/Expression.java       |   5 +
 .../apache/cassandra/index/sai/plan/Operation.java |   2 +-
 .../cassandra/index/sai/plan/QueryController.java  |  41 +-
 .../cassandra/index/sai/plan/QueryViewBuilder.java | 192 +++++
 .../sai/plan/StorageAttachedIndexQueryPlan.java    |   4 +-
 .../sai/plan/StorageAttachedIndexSearcher.java     |  11 +-
 .../index/sai/postings/OrdinalPostingList.java     |  18 +-
 .../index/sai/postings/PeekablePostingList.java    | 116 +++
 .../cassandra/index/sai/postings/PostingList.java  |  60 ++
 .../index/sai/utils/NamedMemoryLimiter.java        |  79 ++
 .../cassandra/index/sai/utils/PrimaryKey.java      | 348 +++++---
 .../index/sai/utils/PrimaryKeyFactory.java         |  57 --
 .../cassandra/index/sai/utils/TermsIterator.java   |  46 +
 .../apache/cassandra/index/sai/utils/TypeUtil.java |   9 +
 .../cassandra/index/sai/view/IndexViewManager.java | 150 ++++
 .../cassandra/index/sai/view/RangeTermTree.java    | 134 +++
 .../org/apache/cassandra/index/sai/view/View.java  | 100 +++
 ...ystemView.java => ColumnIndexesSystemView.java} |   6 +-
 .../sai/virtual/SSTableIndexesSystemView.java      | 132 +++
 .../index/sai/virtual/SegmentsSystemView.java      | 125 +++
 .../sai/virtual/StorageAttachedIndexTables.java    |   6 +-
 .../io/compress/CompressedSequentialWriter.java    |  35 +
 .../apache/cassandra/io/sstable/Descriptor.java    |  10 +-
 .../org/apache/cassandra/io/sstable/SSTable.java   |  40 +
 .../cassandra/io/sstable/format/TOCComponent.java  |  11 +
 .../apache/cassandra/io/tries/ValueIterator.java   |  46 +-
 src/java/org/apache/cassandra/io/tries/Walker.java |  76 +-
 .../apache/cassandra/io/util/DataOutputPlus.java   |   9 +-
 .../org/apache/cassandra/io/util/PageAware.java    |   2 +-
 .../io/util/TailOverridingRebufferer.java          |   2 +-
 .../utils/bytecomparable/ByteComparable.java       |   5 +
 .../utils/concurrent/LightweightRecycler.java      |   2 +-
 .../cassandra/utils/concurrent/ThreadLocals.java   |   2 +-
 .../cassandra/distributed/test/sai/SAIUtil.java    |   4 +-
 .../config/DatabaseDescriptorRefTest.java          |   8 +-
 .../config/StorageAttachedIndexOptionsTest.java    |  44 +
 .../unit/org/apache/cassandra/index/StubIndex.java |   3 +-
 .../cassandra/index/sai/IndexingSchemaLoader.java  | 173 ----
 .../org/apache/cassandra/index/sai/SAITester.java  | 651 ++++++++++++++-
 .../index/sai/cql/AbstractQueryTester.java         |   4 +-
 .../cassandra/index/sai/cql/BaseDataModel.java     |  84 +-
 .../index/sai/cql/CollectionIndexingTest.java      |   2 +-
 .../index/sai/cql/DecimalLargeValueTest.java       |   6 +-
 .../cassandra/index/sai/cql/IndexQuerySupport.java |  52 +-
 .../sai/cql/MixedIndexImplementationsTest.java     |   2 +
 .../index/sai/cql/StorageAttachedIndexDDLTest.java | 924 +++++++++++++++++++--
 .../cassandra/index/sai/cql/types/AsciiTest.java   |   2 +-
 .../cassandra/index/sai/cql/types/BigintTest.java  |   2 +-
 .../cassandra/index/sai/cql/types/BooleanTest.java |   2 +-
 .../cassandra/index/sai/cql/types/DataSet.java     |  20 +-
 .../cassandra/index/sai/cql/types/DateTest.java    |   2 +-
 .../cassandra/index/sai/cql/types/DecimalTest.java |   2 +-
 .../cassandra/index/sai/cql/types/DoubleTest.java  |   2 +-
 .../cassandra/index/sai/cql/types/FloatTest.java   |   2 +-
 .../index/sai/cql/types/IndexingTypeSupport.java   |  51 +-
 .../cassandra/index/sai/cql/types/InetTest.java    |   2 +-
 .../cassandra/index/sai/cql/types/IntTest.java     |   2 +-
 .../sai/cql/types/NumericTypeSortingTest.java      |  20 +-
 .../cassandra/index/sai/cql/types/QuerySet.java    | 126 ++-
 .../index/sai/cql/types/SmallintTest.java          |   2 +-
 .../cassandra/index/sai/cql/types/TextTest.java    |   2 +-
 .../cassandra/index/sai/cql/types/TimeTest.java    |   2 +-
 .../index/sai/cql/types/TimestampTest.java         |   2 +-
 .../index/sai/cql/types/TimeuuidTest.java          |   2 +-
 .../cassandra/index/sai/cql/types/TinyintTest.java |   2 +-
 .../cassandra/index/sai/cql/types/UuidTest.java    |   2 +-
 .../cassandra/index/sai/cql/types/VarintTest.java  |   2 +-
 .../cql/types/collections/CollectionDataSet.java   |  39 +-
 .../collections/lists/FrozenListAsciiTest.java     |   2 +-
 .../collections/lists/FrozenListDecimalTest.java   |   2 +-
 .../types/collections/lists/FrozenListIntTest.java |   2 +-
 .../collections/lists/FrozenListVarintTest.java    |   2 +-
 .../cql/types/collections/lists/ListAsciiTest.java |   2 +-
 .../types/collections/lists/ListBigintTest.java    |   2 +-
 .../cql/types/collections/lists/ListDateTest.java  |   2 +-
 .../types/collections/lists/ListDecimalTest.java   |   2 +-
 .../types/collections/lists/ListDoubleTest.java    |   2 +-
 .../cql/types/collections/lists/ListFloatTest.java |   2 +-
 .../lists/ListFrozenCollectionTest.java            |   2 +-
 .../cql/types/collections/lists/ListInetTest.java  |   2 +-
 .../cql/types/collections/lists/ListIntTest.java   |   2 +-
 .../types/collections/lists/ListSmallintTest.java  |   2 +-
 .../cql/types/collections/lists/ListTextTest.java  |   2 +-
 .../cql/types/collections/lists/ListTimeTest.java  |   2 +-
 .../types/collections/lists/ListTimestampTest.java |   2 +-
 .../types/collections/lists/ListTimeuuidTest.java  |   2 +-
 .../types/collections/lists/ListTinyintTest.java   |   2 +-
 .../cql/types/collections/lists/ListUuidTest.java  |   2 +-
 .../types/collections/lists/ListVarintTest.java    |   2 +-
 .../types/collections/maps/FrozenMapAsciiTest.java |   2 +-
 .../collections/maps/FrozenMapDecimalTest.java     |   2 +-
 .../types/collections/maps/FrozenMapIntTest.java   |   2 +-
 .../collections/maps/FrozenMapVarintTest.java      |   2 +-
 .../cql/types/collections/maps/MapAsciiTest.java   |   2 +-
 .../cql/types/collections/maps/MapBigintTest.java  |   2 +-
 .../cql/types/collections/maps/MapDateTest.java    |   2 +-
 .../cql/types/collections/maps/MapDecimalTest.java |   2 +-
 .../cql/types/collections/maps/MapDoubleTest.java  |   2 +-
 .../collections/maps/MapEntriesAsciiTest.java      |   2 +-
 .../maps/MapEntriesFrozenCollectionTest.java       |   2 +-
 .../types/collections/maps/MapEntriesIntTest.java  |   2 +-
 .../collections/maps/MapEntriesVarintTest.java     |   2 +-
 .../cql/types/collections/maps/MapFloatTest.java   |   2 +-
 .../collections/maps/MapFrozenCollectionTest.java  |   2 +-
 .../cql/types/collections/maps/MapInetTest.java    |   2 +-
 .../sai/cql/types/collections/maps/MapIntTest.java |   2 +-
 .../types/collections/maps/MapKeysAsciiTest.java   |   2 +-
 .../maps/MapKeysFrozenCollectionTest.java          |   2 +-
 .../cql/types/collections/maps/MapKeysIntTest.java |   2 +-
 .../types/collections/maps/MapKeysVarintTest.java  |   2 +-
 .../types/collections/maps/MapSmallintTest.java    |   2 +-
 .../cql/types/collections/maps/MapTextTest.java    |   2 +-
 .../cql/types/collections/maps/MapTimeTest.java    |   2 +-
 .../types/collections/maps/MapTimestampTest.java   |   2 +-
 .../types/collections/maps/MapTimeuuidTest.java    |   2 +-
 .../cql/types/collections/maps/MapTinyintTest.java |   2 +-
 .../cql/types/collections/maps/MapUuidTest.java    |   2 +-
 .../types/collections/maps/MapValuesAsciiTest.java |   2 +-
 .../maps/MapValuesFrozenCollectionTest.java        |   2 +-
 .../types/collections/maps/MapValuesIntTest.java   |   2 +-
 .../collections/maps/MapValuesVarintTest.java      |   2 +-
 .../cql/types/collections/maps/MapVarintTest.java  |   2 +-
 .../types/collections/maps/MultiMapAsciiTest.java  |   2 +-
 .../types/collections/maps/MultiMapIntTest.java    |   2 +-
 .../types/collections/maps/MultiMapVarintTest.java |   2 +-
 .../types/collections/sets/FrozenSetAsciiTest.java |   2 +-
 .../collections/sets/FrozenSetDecimalTest.java     |   2 +-
 .../types/collections/sets/FrozenSetIntTest.java   |   2 +-
 .../collections/sets/FrozenSetVarintTest.java      |   2 +-
 .../cql/types/collections/sets/SetAsciiTest.java   |   2 +-
 .../cql/types/collections/sets/SetBigintTest.java  |   2 +-
 .../cql/types/collections/sets/SetDateTest.java    |   2 +-
 .../cql/types/collections/sets/SetDecimalTest.java |   2 +-
 .../cql/types/collections/sets/SetDoubleTest.java  |   2 +-
 .../cql/types/collections/sets/SetFloatTest.java   |   2 +-
 .../collections/sets/SetFrozenCollectionTest.java  |   2 +-
 .../cql/types/collections/sets/SetInetTest.java    |   2 +-
 .../sai/cql/types/collections/sets/SetIntTest.java |   2 +-
 .../types/collections/sets/SetSmallintTest.java    |   2 +-
 .../cql/types/collections/sets/SetTextTest.java    |   2 +-
 .../cql/types/collections/sets/SetTimeTest.java    |   2 +-
 .../types/collections/sets/SetTimestampTest.java   |   2 +-
 .../types/collections/sets/SetTimeuuidTest.java    |   2 +-
 .../cql/types/collections/sets/SetTinyintTest.java |   2 +-
 .../cql/types/collections/sets/SetUuidTest.java    |   2 +-
 .../cql/types/collections/sets/SetVarintTest.java  |   2 +-
 .../types/multicell/FrozenTupleCollectionTest.java |   2 +-
 .../cql/types/multicell/FrozenTupleDataSet.java    |   2 +-
 .../sai/cql/types/multicell/FrozenTupleTest.java   |   2 +-
 .../cql/types/multicell/FrozenTupleTupleTest.java  |   2 +-
 .../types/multicell/FrozenUDTCollectionTest.java   |   2 +-
 .../sai/cql/types/multicell/FrozenUDTDataSet.java  |   6 +-
 .../sai/cql/types/multicell/FrozenUDTTest.java     |   2 +-
 .../index/sai/cql/types/multicell/TupleTest.java   |   2 +-
 .../cassandra/index/sai/disk/ArrayPostingList.java |  92 ++
 .../cassandra/index/sai/disk/NodeStartupTest.java  | 407 +++++++++
 .../index/sai/disk/SelectiveIntersectionTest.java  | 159 ++++
 .../index/sai/disk/SingleNodeQueryFailureTest.java | 111 +++
 .../index/sai/disk/format/IndexDescriptorTest.java | 122 +++
 .../index/sai/disk/format/VersionTest.java         |  50 ++
 .../index/sai/disk/io/TrackingIndexFileUtils.java  | 116 +++
 .../index/sai/disk/v1/InvertedIndexBuilder.java    |  61 ++
 .../sai/disk/v1/InvertedIndexSearcherTest.java     | 218 +++++
 .../cassandra/index/sai/disk/v1/MetadataTest.java  | 189 +++++
 .../index/sai/disk/v1/SAICodecUtilsTest.java       | 390 +++++++++
 .../index/sai/disk/v1/SegmentFlushTest.java        | 222 +++++
 .../cassandra/index/sai/disk/v1/SegmentTest.java   | 165 ++++
 .../cassandra/index/sai/disk/v1/SorterTest.java    |  71 ++
 .../index/sai/disk/v1/TermsReaderTest.java         | 180 ++++
 .../cassandra/index/sai/disk/v1/TermsScanner.java  |  96 +++
 .../sai/disk/v1/bitpack/NumericValuesTest.java     | 125 +++
 .../sai/disk/v1/postings/MergePostingListTest.java | 333 ++++++++
 .../index/sai/disk/v1/postings/PostingsTest.java   | 394 +++++++++
 .../disk/v1/postings/ScanningPostingsReader.java   |  45 +
 .../sai/disk/v1/sortedterms/SortedTermsTest.java   | 415 +++++++++
 .../sai/disk/v1/trie/TriePrefixSearcherTest.java   | 160 ++++
 .../sai/disk/v1/trie/TrieTermsDictionaryTest.java  | 171 ++++
 .../index/sai/disk/v1/trie/TrieTermsIterator.java  |  82 ++
 .../index/sai/functional/CompactionTest.java       | 339 ++++++++
 .../index/sai/functional/DiskSpaceTest.java        |  56 ++
 .../index/sai/functional/DropTableTest.java        | 106 +++
 .../index/sai/functional/FailureTest.java          | 125 +++
 .../index/sai/functional/FlushingTest.java         |  60 ++
 .../index/sai/functional/GroupComponentsTest.java  | 120 +++
 .../index/sai/functional/NodeRestartTest.java      | 180 ++++
 .../index/sai/functional/SnapshotTest.java         | 167 ++++
 .../AbstractKeyRangeIteratorTester.java            |   9 +-
 .../KeyRangeConcatIteratorTest.java                |  11 +-
 .../KeyRangeIntersectionIteratorTest.java          |   4 +-
 .../KeyRangeUnionIteratorTest.java                 |  76 +-
 .../sai/{utils => iterators}/LongIterator.java     |   7 +-
 .../AbstractInMemoryKeyRangeIteratorTester.java    |   7 +-
 .../sai/memory/InMemoryKeyRangeIteratorTest.java   |   2 +-
 .../PriorityInMemoryKeyRangeIteratorTest.java      |   2 +-
 .../index/sai/memory/RAMPostingSlicesTest.java     |  80 ++
 .../index/sai/memory/RAMStringIndexerTest.java     | 115 +++
 .../index/sai/memory/TrieMemoryIndexTest.java      |   2 +-
 .../FinalSegmentFlushingFailureTest.java}          |  14 +-
 .../index/sai/metrics/IndexGroupMetricsTest.java   |  92 ++
 .../index/sai/metrics/IndexMetricsTest.java        |   8 +-
 .../index/sai/metrics/QueryMetricsTest.java        |  16 +-
 .../sai/metrics/SegmentFlushingFailureTester.java  | 217 +++++
 .../index/sai/metrics/StateMetricsTest.java        |  14 +-
 .../TinySegmentFlushingFailureTest.java}           |  20 +-
 .../cassandra/index/sai/plan/OperationTest.java    | 179 ++--
 .../sai/postings/PeekablePostingListTest.java      |  73 ++
 .../index/sai/utils/IndexInputLeakDetector.java    |  62 ++
 .../index/sai/utils/NamedMemoryLimiterTest.java    |  82 ++
 .../cassandra/index/sai/utils/PrimaryKeyTest.java  |  28 +-
 .../index/sai/utils/SAIRandomizedTester.java       |  89 +-
 .../cassandra/index/sai/utils/TypeUtilTest.java    | 251 ++++++
 .../index/sai/virtual/IndexesSystemViewTest.java   |  70 +-
 .../index/sai/virtual/SSTablesSystemViewTest.java  | 201 +++++
 .../index/sai/virtual/SegmentsSystemViewTest.java  | 220 +++++
 .../cassandra/io/tries/AbstractTrieTestBase.java   |   6 +
 .../org/apache/cassandra/io/tries/WalkerTest.java  |   2 +-
 309 files changed, 21598 insertions(+), 1305 deletions(-)

diff --git a/conf/cassandra.yaml b/conf/cassandra.yaml
index 30fb7eac8b..9e79d3525b 100644
--- a/conf/cassandra.yaml
+++ b/conf/cassandra.yaml
@@ -1545,6 +1545,12 @@ transparent_data_encryption_options:
           store_type: JCEKS
           key_password: cassandra
 
+# Storage Attached Indexing options.
+# sai_options:
+  ## Total permitted memory allowed for writing SAI index segments. This memory
+  ## is split between all SAI indexes being built so more indexes will mean smaller
+  ## segment sizes.
+  # segment_write_buffer_size: 1024MiB
 
 #####################
 # SAFETY THRESHOLDS #
diff --git a/src/java/org/apache/cassandra/config/CassandraRelevantProperties.java b/src/java/org/apache/cassandra/config/CassandraRelevantProperties.java
index 6fba0d20e7..7a0cfafc90 100644
--- a/src/java/org/apache/cassandra/config/CassandraRelevantProperties.java
+++ b/src/java/org/apache/cassandra/config/CassandraRelevantProperties.java
@@ -419,6 +419,8 @@ public enum CassandraRelevantProperties
     // SAI specific properties
     /** Controls the maximum number of index query intersections that will take part in a query */
     SAI_INTERSECTION_CLAUSE_LIMIT("cassandra.sai.intersection.clause.limit", "2"),
+    /** Latest version to be used for SAI index writing */
+    SAI_LATEST_VERSION("cassandra.sai.latest_version", "aa"),
 
     /** Defines how often schema definitions are pulled from the other nodes */
     SCHEMA_PULL_INTERVAL_MS("cassandra.schema_pull_interval_ms", "60000"),
diff --git a/src/java/org/apache/cassandra/config/Config.java b/src/java/org/apache/cassandra/config/Config.java
index 21d307c601..47b197cf9b 100644
--- a/src/java/org/apache/cassandra/config/Config.java
+++ b/src/java/org/apache/cassandra/config/Config.java
@@ -777,6 +777,8 @@ public class Config
      */
     public volatile double range_tombstone_list_growth_factor = 1.5;
 
+    public StorageAttachedIndexOptions sai_options = new StorageAttachedIndexOptions();
+
     /**
      * @deprecated migrate to {@link DatabaseDescriptor#isClientInitialized()}
      */
diff --git a/src/java/org/apache/cassandra/config/DatabaseDescriptor.java b/src/java/org/apache/cassandra/config/DatabaseDescriptor.java
index cb3fd9159f..043cb9b5bc 100644
--- a/src/java/org/apache/cassandra/config/DatabaseDescriptor.java
+++ b/src/java/org/apache/cassandra/config/DatabaseDescriptor.java
@@ -967,6 +967,8 @@ public class DatabaseDescriptor
 
         if (conf.dump_heap_on_uncaught_exception && DatabaseDescriptor.getHeapDumpPath() == null)
             throw new ConfigurationException(String.format("Invalid configuration. Heap dump is enabled but cannot create heap dump output path: %s.", conf.heap_dump_path != null ? conf.heap_dump_path : "null"));
+
+        conf.sai_options.validate();
     }
 
     @VisibleForTesting
@@ -4756,4 +4758,9 @@ public class DatabaseDescriptor
     {
         return conf != null ? conf.default_compaction : null;
     }
+
+    public static DataStorageSpec.IntMebibytesBound getSAISegmentWriteBufferSpace()
+    {
+        return conf.sai_options.segment_write_buffer_size;
+    }
 }
diff --git a/src/java/org/apache/cassandra/config/StorageAttachedIndexOptions.java b/src/java/org/apache/cassandra/config/StorageAttachedIndexOptions.java
new file mode 100644
index 0000000000..18bf3506fe
--- /dev/null
+++ b/src/java/org/apache/cassandra/config/StorageAttachedIndexOptions.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.config;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import org.apache.cassandra.exceptions.ConfigurationException;
+
+public class StorageAttachedIndexOptions
+{
+    public static final int DEFAULT_SEGMENT_BUFFER_MB = 1024;
+
+    @VisibleForTesting
+    public static final int MAXIMUM_SEGMENT_BUFFER_MB = 32768;
+
+    @VisibleForTesting
+    public static final String INVALID_BUFFER_SIZE_ERROR = "Invalid value for segment_write_buffer_size. " +
+                                                           "Value must be a positive integer less than " + MAXIMUM_SEGMENT_BUFFER_MB + "MiB";
+
+    public DataStorageSpec.IntMebibytesBound segment_write_buffer_size = new DataStorageSpec.IntMebibytesBound(DEFAULT_SEGMENT_BUFFER_MB);
+
+    public void validate()
+    {
+        if (segment_write_buffer_size.toMebibytes() > MAXIMUM_SEGMENT_BUFFER_MB)
+        {
+            throw new ConfigurationException(INVALID_BUFFER_SIZE_ERROR);
+        }
+    }
+}
diff --git a/src/java/org/apache/cassandra/cql3/statements/schema/CreateIndexStatement.java b/src/java/org/apache/cassandra/cql3/statements/schema/CreateIndexStatement.java
index 73a7b6aac6..73b66b738d 100644
--- a/src/java/org/apache/cassandra/cql3/statements/schema/CreateIndexStatement.java
+++ b/src/java/org/apache/cassandra/cql3/statements/schema/CreateIndexStatement.java
@@ -48,8 +48,35 @@ import static com.google.common.collect.Iterables.tryFind;
 
 public final class CreateIndexStatement extends AlterSchemaStatement
 {
+    public static final String SASI_INDEX_DISABLED = "SASI indexes are disabled. Enable in cassandra.yaml to use.";
+    public static final String KEYSPACE_DOES_NOT_EXIST = "Keyspace '%s' doesn't exist";
+    public static final String TABLE_DOES_NOT_EXIST = "Table '%s' doesn't exist";
+    public static final String COUNTER_TABLES_NOT_SUPPORTED = "Secondary indexes on counter tables aren't supported";
+    public static final String MATERIALIZED_VIEWS_NOT_SUPPORTED = "Secondary indexes on materialized views aren't supported";
+    public static final String TRANSIENTLY_REPLICATED_KEYSPACE_NOT_SUPPORTED = "Secondary indexes are not supported on transiently replicated keyspaces";
+    public static final String CUSTOM_CREATE_WITHOUT_COLUMN = "Only CUSTOM indexes can be created without specifying a target column";
+    public static final String CUSTOM_MULTIPLE_COLUMNS = "Only CUSTOM indexes support multiple columns";
+    public static final String DUPLICATE_TARGET_COLUMN = "Duplicate column '%s' in index target list";
+    public static final String COLUMN_DOES_NOT_EXIST = "Column '%s' doesn't exist";
     public static final String INVALID_CUSTOM_INDEX_TARGET = "Column '%s' is longer than the permissible name length of %d characters or" +
                                                              " contains non-alphanumeric-underscore characters";
+    public static final String COLLECTIONS_WITH_DURATIONS_NOT_SUPPORTED = "Secondary indexes are not supported on collections containing durations";
+    public static final String TUPLES_WITH_DURATIONS_NOT_SUPPORTED = "Secondary indexes are not supported on tuples containing durations";
+    public static final String DURATIONS_NOT_SUPPORTED = "Secondary indexes are not supported on duration columns";
+    public static final String UDTS_WITH_DURATIONS_NOT_SUPPORTED = "Secondary indexes are not supported on UDTs containing durations";
+    public static final String PRIMARY_KEY_IN_COMPACT_STORAGE = "Secondary indexes are not supported on PRIMARY KEY columns in COMPACT STORAGE tables";
+    public static final String COMPACT_COLUMN_IN_COMPACT_STORAGE = "Secondary indexes are not supported on compact value column of COMPACT STORAGE tables";
+    public static final String ONLY_PARTITION_KEY = "Cannot create secondary index on the only partition key column %s";
+    public static final String CREATE_ON_FROZEN_COLUMN = "Cannot create %s() index on frozen column %s. Frozen collections are immutable and must be fully " +
+                                                         "indexed by using the 'full(%s)' modifier";
+    public static final String FULL_ON_FROZEN_COLLECTIONS = "full() indexes can only be created on frozen collections";
+    public static final String NON_COLLECTION_SIMPLE_INDEX = "Cannot create %s() index on %s. Non-collection columns only support simple indexes";
+    public static final String CREATE_WITH_NON_MAP_TYPE = "Cannot create index on %s of column %s with non-map type";
+    public static final String CREATE_ON_NON_FROZEN_UDT = "Cannot create index on non-frozen UDT column %s";
+    public static final String INDEX_ALREADY_EXISTS = "Index '%s' already exists";
+    public static final String INDEX_DUPLICATE_OF_EXISTING = "Index %s is a duplicate of existing index %s";
+    public static final String KEYSPACE_DOES_NOT_MATCH_TABLE = "Keyspace name '%s' doesn't match table name '%s'";
+    public static final String KEYSPACE_DOES_NOT_MATCH_INDEX = "Keyspace name '%s' doesn't match index name '%s'";
 
     private final String indexName;
     private final String tableName;
@@ -90,32 +117,32 @@ public final class CreateIndexStatement extends AlterSchemaStatement
         Guardrails.createSecondaryIndexesEnabled.ensureEnabled("Creating secondary indexes", state);
 
         if (attrs.isCustom && attrs.customClass.equals(SASIIndex.class.getName()) && !DatabaseDescriptor.getSASIIndexesEnabled())
-            throw new InvalidRequestException("SASI indexes are disabled. Enable in cassandra.yaml to use.");
+            throw new InvalidRequestException(SASI_INDEX_DISABLED);
 
         KeyspaceMetadata keyspace = schema.getNullable(keyspaceName);
         if (null == keyspace)
-            throw ire("Keyspace '%s' doesn't exist", keyspaceName);
+            throw ire(KEYSPACE_DOES_NOT_EXIST, keyspaceName);
 
         TableMetadata table = keyspace.getTableOrViewNullable(tableName);
         if (null == table)
-            throw ire("Table '%s' doesn't exist", tableName);
+            throw ire(TABLE_DOES_NOT_EXIST, tableName);
 
         if (null != indexName && keyspace.hasIndex(indexName))
         {
             if (ifNotExists)
                 return schema;
 
-            throw ire("Index '%s' already exists", indexName);
+            throw ire(INDEX_ALREADY_EXISTS, indexName);
         }
 
         if (table.isCounter())
-            throw ire("Secondary indexes on counter tables aren't supported");
+            throw ire(COUNTER_TABLES_NOT_SUPPORTED);
 
         if (table.isView())
-            throw ire("Secondary indexes on materialized views aren't supported");
+            throw ire(MATERIALIZED_VIEWS_NOT_SUPPORTED);
 
         if (Keyspace.open(table.keyspace).getReplicationStrategy().hasTransientReplicas())
-            throw new InvalidRequestException("Secondary indexes are not supported on transiently replicated keyspaces");
+            throw new InvalidRequestException(TRANSIENTLY_REPLICATED_KEYSPACE_NOT_SUPPORTED);
 
         // guardrails to limit number of secondary indexes per table.
         Guardrails.secondaryIndexesPerTable.guard(table.indexes.size() + 1,
@@ -128,17 +155,17 @@ public final class CreateIndexStatement extends AlterSchemaStatement
         List<IndexTarget> indexTargets = Lists.newArrayList(transform(rawIndexTargets, t -> t.prepare(table)));
 
         if (indexTargets.isEmpty() && !attrs.isCustom)
-            throw ire("Only CUSTOM indexes can be created without specifying a target column");
+            throw ire(CUSTOM_CREATE_WITHOUT_COLUMN);
 
         if (indexTargets.size() > 1)
         {
             if (!attrs.isCustom)
-                throw ire("Only CUSTOM indexes support multiple columns");
+                throw ire(CUSTOM_MULTIPLE_COLUMNS);
 
             Set<ColumnIdentifier> columns = new HashSet<>();
             for (IndexTarget target : indexTargets)
                 if (!columns.add(target.column))
-                    throw ire("Duplicate column '%s' in index target list", target.column);
+                    throw ire(DUPLICATE_TARGET_COLUMN, target.column);
         }
 
         IndexMetadata.Kind kind = attrs.isCustom ? IndexMetadata.Kind.CUSTOM : IndexMetadata.Kind.COMPOSITES;
@@ -158,7 +185,7 @@ public final class CreateIndexStatement extends AlterSchemaStatement
             if (ifNotExists)
                 return schema;
 
-            throw ire("Index %s is a duplicate of existing index %s", index.name, equalIndex.name);
+            throw ire(INDEX_DUPLICATE_OF_EXISTING, index.name, equalIndex.name);
         }
 
         TableMetadata newTable = table.withSwapped(table.indexes.with(index));
@@ -181,7 +208,7 @@ public final class CreateIndexStatement extends AlterSchemaStatement
         ColumnMetadata column = table.getColumn(target.column);
 
         if (null == column)
-            throw ire("Column '%s' doesn't exist", target.column);
+            throw ire(COLUMN_DOES_NOT_EXIST, target.column);
 
         if ((kind == IndexMetadata.Kind.CUSTOM) && !SchemaConstants.isValidName(target.column.toString()))
             throw ire(INVALID_CUSTOM_INDEX_TARGET, target.column, SchemaConstants.NAME_LENGTH);
@@ -189,44 +216,43 @@ public final class CreateIndexStatement extends AlterSchemaStatement
         if (column.type.referencesDuration())
         {
             if (column.type.isCollection())
-                throw ire("Secondary indexes are not supported on collections containing durations");
+                throw ire(COLLECTIONS_WITH_DURATIONS_NOT_SUPPORTED);
 
             if (column.type.isTuple())
-                throw ire("Secondary indexes are not supported on tuples containing durations");
+                throw ire(TUPLES_WITH_DURATIONS_NOT_SUPPORTED);
 
             if (column.type.isUDT())
-                throw  ire("Secondary indexes are not supported on UDTs containing durations");
+                throw  ire(UDTS_WITH_DURATIONS_NOT_SUPPORTED);
 
-            throw ire("Secondary indexes are not supported on duration columns");
+            throw ire(DURATIONS_NOT_SUPPORTED);
         }
 
         if (table.isCompactTable())
         {
             TableMetadata.CompactTableMetadata compactTable = (TableMetadata.CompactTableMetadata) table;
             if (column.isPrimaryKeyColumn())
-                throw new InvalidRequestException("Secondary indexes are not supported on PRIMARY KEY columns in COMPACT STORAGE tables");
+                throw new InvalidRequestException(PRIMARY_KEY_IN_COMPACT_STORAGE);
             if (compactTable.compactValueColumn.equals(column))
-                throw new InvalidRequestException("Secondary indexes are not supported on compact value column of COMPACT STORAGE tables");
+                throw new InvalidRequestException(COMPACT_COLUMN_IN_COMPACT_STORAGE);
         }
 
         if (column.isPartitionKey() && table.partitionKeyColumns().size() == 1)
-            throw ire("Cannot create secondary index on the only partition key column %s", column);
+            throw ire(ONLY_PARTITION_KEY, column);
 
         if (column.type.isFrozenCollection() && target.type != Type.FULL)
-            throw ire("Cannot create %s() index on frozen column %s. Frozen collections are immutable and must be fully " +
-                      "indexed by using the 'full(%s)' modifier", target.type, column, column);
+            throw ire(CREATE_ON_FROZEN_COLUMN, target.type, column, column);
 
         if (!column.type.isFrozenCollection() && target.type == Type.FULL)
-            throw ire("full() indexes can only be created on frozen collections");
+            throw ire(FULL_ON_FROZEN_COLLECTIONS);
 
         if (!column.type.isCollection() && target.type != Type.SIMPLE)
-            throw ire("Cannot create %s() index on %s. Non-collection columns only support simple indexes", target.type, column);
+            throw ire(NON_COLLECTION_SIMPLE_INDEX, target.type, column);
 
         if (!(column.type instanceof MapType && column.type.isMultiCell()) && (target.type == Type.KEYS || target.type == Type.KEYS_AND_VALUES))
-            throw ire("Cannot create index on %s of column %s with non-map type", target.type, column);
+            throw ire(CREATE_WITH_NON_MAP_TYPE, target.type, column);
 
         if (column.type.isUDT() && column.type.isMultiCell())
-            throw ire("Cannot create index on non-frozen UDT column %s", column);
+            throw ire(CREATE_ON_NON_FROZEN_UDT, column);
     }
 
     private String generateIndexName(KeyspaceMetadata keyspace, List<IndexTarget> targets)
@@ -286,10 +312,10 @@ public final class CreateIndexStatement extends AlterSchemaStatement
                                 : indexName.hasKeyspace() ? indexName.getKeyspace() : state.getKeyspace();
 
             if (tableName.hasKeyspace() && !keyspaceName.equals(tableName.getKeyspace()))
-                throw ire("Keyspace name '%s' doesn't match table name '%s'", keyspaceName, tableName);
+                throw ire(KEYSPACE_DOES_NOT_MATCH_TABLE, keyspaceName, tableName);
 
             if (indexName.hasKeyspace() && !keyspaceName.equals(indexName.getKeyspace()))
-                throw ire("Keyspace name '%s' doesn't match index name '%s'", keyspaceName, tableName);
+                throw ire(KEYSPACE_DOES_NOT_MATCH_INDEX, keyspaceName, tableName);
 
             return new CreateIndexStatement(keyspaceName, tableName.getName(), indexName.getName(), rawIndexTargets, attrs, ifNotExists);
         }
diff --git a/src/java/org/apache/cassandra/db/ReadCommand.java b/src/java/org/apache/cassandra/db/ReadCommand.java
index d46ce1efa2..2ca47ea06f 100644
--- a/src/java/org/apache/cassandra/db/ReadCommand.java
+++ b/src/java/org/apache/cassandra/db/ReadCommand.java
@@ -411,7 +411,6 @@ public abstract class ReadCommand extends AbstractReadQuery
             {
                 cfs.indexManager.checkQueryability(indexQueryPlan);
 
-                Index index = indexQueryPlan.getFirst();
                 searcher = indexQueryPlan.searcherFor(this);
                 Tracing.trace("Executing read on {}.{} using index{} {}",
                               cfs.metadata.keyspace,
diff --git a/src/java/org/apache/cassandra/db/lifecycle/Tracker.java b/src/java/org/apache/cassandra/db/lifecycle/Tracker.java
index 6f2b8c6d85..272bbd9305 100644
--- a/src/java/org/apache/cassandra/db/lifecycle/Tracker.java
+++ b/src/java/org/apache/cassandra/db/lifecycle/Tracker.java
@@ -217,6 +217,12 @@ public class Tracker
         return accumulate;
     }
 
+    public void updateLiveDiskSpaceUsed(long adjustment)
+    {
+        cfstore.metric.liveDiskSpaceUsed.inc(adjustment);
+        cfstore.metric.totalDiskSpaceUsed.inc(adjustment);
+    }
+
     // SETUP / CLEANUP
 
     public void addInitialSSTables(Iterable<SSTableReader> sstables)
diff --git a/src/java/org/apache/cassandra/db/memtable/AbstractMemtable.java b/src/java/org/apache/cassandra/db/memtable/AbstractMemtable.java
index da62e46dec..2f2c2a2551 100644
--- a/src/java/org/apache/cassandra/db/memtable/AbstractMemtable.java
+++ b/src/java/org/apache/cassandra/db/memtable/AbstractMemtable.java
@@ -30,6 +30,7 @@ import com.google.common.annotations.VisibleForTesting;
 
 import org.apache.cassandra.db.RegularAndStaticColumns;
 import org.apache.cassandra.db.commitlog.CommitLogPosition;
+import org.apache.cassandra.db.lifecycle.LifecycleTransaction;
 import org.apache.cassandra.db.partitions.Partition;
 import org.apache.cassandra.db.rows.EncodingStats;
 import org.apache.cassandra.schema.ColumnMetadata;
@@ -39,6 +40,7 @@ import org.github.jamm.Unmetered;
 
 public abstract class AbstractMemtable implements Memtable
 {
+    private final AtomicReference<LifecycleTransaction> flushTransaction = new AtomicReference<>(null);
     protected final AtomicLong currentOperations = new AtomicLong(0);
     protected final ColumnsCollector columnsCollector;
     protected final StatsCollector statsCollector = new StatsCollector();
@@ -66,21 +68,25 @@ public abstract class AbstractMemtable implements Memtable
         this.minTimestamp = new AtomicLong(minTimestamp);
     }
 
+    @Override
     public TableMetadata metadata()
     {
         return metadata.get();
     }
 
+    @Override
     public long operationCount()
     {
         return currentOperations.get();
     }
 
+    @Override
     public long getMinTimestamp()
     {
         return minTimestamp.get() != EncodingStats.NO_STATS.minTimestamp ? minTimestamp.get() : NO_MIN_TIMESTAMP;
     }
 
+    @Override
     public long getMinLocalDeletionTime()
     {
         return minLocalDeletionTime.get();
@@ -120,6 +126,18 @@ public abstract class AbstractMemtable implements Memtable
         return statsCollector.get();
     }
 
+    @Override
+    public LifecycleTransaction getFlushTransaction()
+    {
+        return flushTransaction.get();
+    }
+
+    @Override
+    public LifecycleTransaction setFlushTransaction(LifecycleTransaction flushTransaction)
+    {
+        return this.flushTransaction.getAndSet(flushTransaction);
+    }
+
     protected static class ColumnsCollector
     {
         private final HashMap<ColumnMetadata, AtomicBoolean> predefined = new HashMap<>();
@@ -205,26 +223,31 @@ public abstract class AbstractMemtable implements Memtable
 
     protected abstract class AbstractFlushablePartitionSet<P extends Partition> implements FlushablePartitionSet<P>
     {
+        @Override
         public long dataSize()
         {
             return getLiveDataSize();
         }
 
+        @Override
         public CommitLogPosition commitLogLowerBound()
         {
             return AbstractMemtable.this.getCommitLogLowerBound();
         }
 
+        @Override
         public LastCommitLogPosition commitLogUpperBound()
         {
             return AbstractMemtable.this.getFinalCommitLogUpperBound();
         }
 
+        @Override
         public EncodingStats encodingStats()
         {
             return AbstractMemtable.this.encodingStats();
         }
 
+        @Override
         public RegularAndStaticColumns columns()
         {
             return AbstractMemtable.this.columns();
diff --git a/src/java/org/apache/cassandra/db/memtable/Flushing.java b/src/java/org/apache/cassandra/db/memtable/Flushing.java
index f97f59518d..6e269515c7 100644
--- a/src/java/org/apache/cassandra/db/memtable/Flushing.java
+++ b/src/java/org/apache/cassandra/db/memtable/Flushing.java
@@ -23,6 +23,7 @@ import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.Callable;
 
+import com.google.common.base.Preconditions;
 import com.google.common.base.Throwables;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -57,6 +58,12 @@ public class Flushing
                                                      Memtable memtable,
                                                      LifecycleTransaction txn)
     {
+        LifecycleTransaction ongoingFlushTransaction = memtable.setFlushTransaction(txn);
+        Preconditions.checkState(ongoingFlushTransaction == null,
+                                 "Attempted to flush Memtable more than once on %s.%s",
+                                 cfs.keyspace.getName(),
+                                 cfs.name);
+
         DiskBoundaries diskBoundaries = cfs.getDiskBoundaries();
         List<PartitionPosition> boundaries = diskBoundaries.positions;
         List<Directories.DataDirectory> locations = diskBoundaries.directories;
diff --git a/src/java/org/apache/cassandra/db/memtable/Memtable.java b/src/java/org/apache/cassandra/db/memtable/Memtable.java
index 3a18a8c7d7..603e3bc028 100644
--- a/src/java/org/apache/cassandra/db/memtable/Memtable.java
+++ b/src/java/org/apache/cassandra/db/memtable/Memtable.java
@@ -26,6 +26,7 @@ import org.apache.cassandra.db.ColumnFamilyStore;
 import org.apache.cassandra.db.PartitionPosition;
 import org.apache.cassandra.db.RegularAndStaticColumns;
 import org.apache.cassandra.db.commitlog.CommitLogPosition;
+import org.apache.cassandra.db.lifecycle.LifecycleTransaction;
 import org.apache.cassandra.db.partitions.Partition;
 import org.apache.cassandra.db.partitions.PartitionUpdate;
 import org.apache.cassandra.db.rows.EncodingStats;
@@ -379,6 +380,10 @@ public interface Memtable extends Comparable<Memtable>, UnfilteredSource
     /** True if the memtable contains no data */
     boolean isClean();
 
+    // The following two methods provide a way of tracking ongoing flushes
+    LifecycleTransaction setFlushTransaction(LifecycleTransaction transaction);
+    LifecycleTransaction getFlushTransaction();
+
     /** Order memtables by time as reflected in the commit log position at time of construction */
     default int compareTo(Memtable that)
     {
diff --git a/src/java/org/apache/cassandra/index/sai/IndexContext.java b/src/java/org/apache/cassandra/index/sai/IndexContext.java
index fc03796ec1..032f28967c 100644
--- a/src/java/org/apache/cassandra/index/sai/IndexContext.java
+++ b/src/java/org/apache/cassandra/index/sai/IndexContext.java
@@ -21,51 +21,52 @@ package org.apache.cassandra.index.sai;
 import java.nio.ByteBuffer;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.Iterator;
 import java.util.Objects;
 import java.util.Set;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ConcurrentMap;
-import java.util.concurrent.TimeUnit;
 import javax.annotation.Nullable;
 
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableSet;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import org.apache.cassandra.cql3.Operator;
 import org.apache.cassandra.cql3.statements.schema.IndexTarget;
 import org.apache.cassandra.db.ClusteringComparator;
 import org.apache.cassandra.db.DecoratedKey;
-import org.apache.cassandra.db.PartitionPosition;
 import org.apache.cassandra.db.marshal.AbstractType;
 import org.apache.cassandra.db.marshal.AsciiType;
 import org.apache.cassandra.db.marshal.BooleanType;
 import org.apache.cassandra.db.marshal.CompositeType;
 import org.apache.cassandra.db.marshal.UTF8Type;
 import org.apache.cassandra.db.marshal.UUIDType;
-import org.apache.cassandra.db.memtable.Memtable;
 import org.apache.cassandra.db.rows.Cell;
 import org.apache.cassandra.db.rows.Row;
-import org.apache.cassandra.dht.AbstractBounds;
 import org.apache.cassandra.index.sai.analyzer.AbstractAnalyzer;
-import org.apache.cassandra.index.sai.memory.MemtableIndex;
+import org.apache.cassandra.index.sai.disk.SSTableIndex;
+import org.apache.cassandra.index.sai.disk.format.Version;
+import org.apache.cassandra.index.sai.memory.MemtableIndexManager;
+import org.apache.cassandra.index.sai.metrics.ColumnQueryMetrics;
 import org.apache.cassandra.index.sai.metrics.IndexMetrics;
 import org.apache.cassandra.index.sai.plan.Expression;
 import org.apache.cassandra.index.sai.utils.PrimaryKey;
-import org.apache.cassandra.index.sai.utils.PrimaryKeyFactory;
-import org.apache.cassandra.index.sai.utils.KeyRangeIterator;
-import org.apache.cassandra.index.sai.utils.KeyRangeUnionIterator;
 import org.apache.cassandra.index.sai.utils.TypeUtil;
+import org.apache.cassandra.index.sai.view.IndexViewManager;
+import org.apache.cassandra.index.sai.view.View;
+import org.apache.cassandra.io.sstable.format.SSTableReader;
 import org.apache.cassandra.schema.ColumnMetadata;
 import org.apache.cassandra.schema.IndexMetadata;
-import org.apache.cassandra.utils.Clock;
-import org.apache.cassandra.utils.FBUtilities;
+import org.apache.cassandra.utils.Pair;
 
 /**
- * Manage metadata for each column index.
+ * Manages metadata for each column index.
  */
 public class IndexContext
 {
+    private static final Logger logger = LoggerFactory.getLogger(IndexContext.class);
+
     private static final Set<AbstractType<?>> EQ_ONLY_TYPES = ImmutableSet.of(UTF8Type.instance,
                                                                               AsciiType.instance,
                                                                               BooleanType.instance,
@@ -82,15 +83,16 @@ public class IndexContext
 
     // Config can be null if the column context is "fake" (i.e. created for a filtering expression).
     @Nullable
-    private final IndexMetadata config;
+    private final IndexMetadata indexMetadata;
 
-    private final ConcurrentMap<Memtable, MemtableIndex> liveMemtableIndexMap;
+    private final MemtableIndexManager memtableIndexManager;
 
+    private final IndexViewManager viewManager;
     private final IndexMetrics indexMetrics;
-
+    private final ColumnQueryMetrics columnQueryMetrics;
     private final AbstractAnalyzer.AnalyzerFactory indexAnalyzerFactory;
     private final AbstractAnalyzer.AnalyzerFactory queryAnalyzerFactory;
-    private final PrimaryKeyFactory primaryKeyFactory;
+    private final PrimaryKey.Factory primaryKeyFactory;
 
     public IndexContext(String keyspace,
                         String table,
@@ -98,7 +100,7 @@ public class IndexContext
                         ClusteringComparator clusteringComparator,
                         ColumnMetadata columnMetadata,
                         IndexTarget.Type indexType,
-                        @Nullable IndexMetadata config)
+                        @Nullable IndexMetadata indexMetadata)
     {
         this.keyspace = Objects.requireNonNull(keyspace);
         this.table = Objects.requireNonNull(table);
@@ -107,11 +109,13 @@ public class IndexContext
         this.columnMetadata = Objects.requireNonNull(columnMetadata);
         this.indexType = Objects.requireNonNull(indexType);
         this.validator = TypeUtil.cellValueType(columnMetadata, indexType);
-        this.primaryKeyFactory = PrimaryKey.factory(clusteringComparator);
+        this.primaryKeyFactory = new PrimaryKey.Factory(clusteringComparator);
 
-        this.config = config;
-        this.indexMetrics = config == null ? null : new IndexMetrics(this);
-        this.liveMemtableIndexMap = config == null ? null : new ConcurrentHashMap<>();
+        this.indexMetadata = indexMetadata;
+        this.memtableIndexManager = indexMetadata == null ? null : new MemtableIndexManager(this);
+        this.indexMetrics = indexMetadata == null ? null : new IndexMetrics(this);
+        this.viewManager = new IndexViewManager(this);
+        this.columnQueryMetrics = new ColumnQueryMetrics.TrieIndexMetrics(this);
 
         // We currently only support the NoOpAnalyzer
         this.indexAnalyzerFactory = AbstractAnalyzer.fromOptions(getValidator(), Collections.emptyMap());
@@ -123,122 +127,42 @@ public class IndexContext
         return partitionKeyType;
     }
 
-    public PrimaryKeyFactory keyFactory()
+    public PrimaryKey.Factory keyFactory()
     {
         return primaryKeyFactory;
     }
 
-    public ClusteringComparator comparator()
-    {
-        return clusteringComparator;
-    }
-
     public String getKeyspace()
     {
         return keyspace;
     }
 
-    public String getTable()
+    public IndexMetrics getIndexMetrics()
     {
-        return table;
+        return indexMetrics;
     }
 
-    public IndexMetadata getIndexMetadata()
+    public ColumnQueryMetrics getColumnQueryMetrics()
     {
-        return config;
-    }
-
-    public long index(DecoratedKey key, Row row, Memtable mt)
-    {
-        assert config != null : "Attempt to index on a non-indexing context";
-
-        MemtableIndex current = liveMemtableIndexMap.get(mt);
-
-        // We expect the relevant IndexMemtable to be present most of the time, so only make the
-        // call to computeIfAbsent() if it's not. (see https://bugs.openjdk.java.net/browse/JDK-8161372)
-        MemtableIndex target = (current != null)
-                               ? current
-                               : liveMemtableIndexMap.computeIfAbsent(mt, memtable -> new MemtableIndex(this));
-
-        long start = Clock.Global.nanoTime();
-
-        long bytes = 0;
-
-        if (isNonFrozenCollection())
-        {
-            Iterator<ByteBuffer> bufferIterator = getValuesOf(row, FBUtilities.nowInSeconds());
-            if (bufferIterator != null)
-            {
-                while (bufferIterator.hasNext())
-                {
-                    ByteBuffer value = bufferIterator.next();
-                    bytes += target.index(key, row.clustering(), value);
-                }
-            }
-        }
-        else
-        {
-            ByteBuffer value = getValueOf(key, row, FBUtilities.nowInSeconds());
-            target.index(key, row.clustering(), value);
-        }
-        indexMetrics.memtableIndexWriteLatency.update(Clock.Global.nanoTime() - start, TimeUnit.NANOSECONDS);
-        return bytes;
+        return columnQueryMetrics;
     }
 
-    public void renewMemtable(Memtable renewed)
-    {
-        assert liveMemtableIndexMap != null : "Attempt to renew memtable on non-indexing context";
-
-        for (Memtable memtable : liveMemtableIndexMap.keySet())
-        {
-            // remove every index but the one that corresponds to the post-truncate Memtable
-            if (renewed != memtable)
-            {
-                liveMemtableIndexMap.remove(memtable);
-            }
-        }
-    }
-
-    public void discardMemtable(Memtable discarded)
-    {
-        assert liveMemtableIndexMap != null : "Attempt to discard memtable from non-indexing context";
-
-        liveMemtableIndexMap.remove(discarded);
-    }
-
-    public KeyRangeIterator searchMemtableIndexes(Expression e, AbstractBounds<PartitionPosition> keyRange)
+    public String getTable()
     {
-        assert liveMemtableIndexMap != null : "Attempt to perform search on non-indexing context";
-
-        Collection<MemtableIndex> memtableIndexes = liveMemtableIndexMap.values();
-
-        if (memtableIndexes.isEmpty())
-        {
-            return KeyRangeIterator.empty();
-        }
-
-        KeyRangeIterator.Builder builder = KeyRangeUnionIterator.builder(memtableIndexes.size());
-
-        for (MemtableIndex memtableIndex : memtableIndexes)
-        {
-            builder.add(memtableIndex.search(e, keyRange));
-        }
-
-        return builder.build();
+        return table;
     }
 
-    public long liveMemtableWriteCount()
+    public IndexMetadata getIndexMetadata()
     {
-        assert liveMemtableIndexMap != null : "Attempt to get metrics from non-indexing context";
-
-        return liveMemtableIndexMap.values().stream().mapToLong(MemtableIndex::writeCount).sum();
+        return indexMetadata;
     }
 
-    public long estimatedMemIndexMemoryUsed()
+    /**
+     * @return A set of SSTables which have attached to them invalid index components.
+     */
+    public Collection<SSTableContext> onSSTableChanged(Collection<SSTableReader> oldSSTables, Collection<SSTableContext> newSSTables, boolean validate)
     {
-        assert liveMemtableIndexMap != null : "Attempt to get metrics from non-indexing context";
-
-        return liveMemtableIndexMap.values().stream().mapToLong(MemtableIndex::estimatedMemoryUsed).sum();
+        return viewManager.update(oldSSTables, newSSTables, validate);
     }
 
     public ColumnMetadata getDefinition()
@@ -269,7 +193,7 @@ public class IndexContext
     @Nullable
     public String getIndexName()
     {
-        return config == null ? null : config.name;
+        return indexMetadata == null ? null : indexMetadata.name;
     }
 
     /**
@@ -292,26 +216,53 @@ public class IndexContext
         return queryAnalyzerFactory;
     }
 
-    public boolean isIndexed()
+    public View getView()
     {
-        return config != null;
+        return viewManager.getView();
+    }
+
+    public MemtableIndexManager getMemtableIndexManager()
+    {
+        assert memtableIndexManager != null : "Attempt to use memtable index manager on non-indexed context";
+
+        return memtableIndexManager;
+    }
+
+    /**
+     * @return total number of per-index open files
+     */
+    public int openPerIndexFiles()
+    {
+        return viewManager.getView().size() * Version.LATEST.onDiskFormat().openFilesPerColumnIndex(this);
+    }
+
+    public void drop(Collection<SSTableReader> sstablesToRebuild)
+    {
+        viewManager.drop(sstablesToRebuild);
+    }
+
+    public boolean isNotIndexed()
+    {
+        return indexMetadata == null;
     }
 
     /**
      * Called when index is dropped. Clear all live in-memory indexes and close
-     * analyzer factories.
+     * analyzer factories. Mark all {@link SSTableIndex} as released and per-column index files
+     * will be removed when in-flight queries are completed.
      */
     public void invalidate()
     {
-        if (liveMemtableIndexMap != null)
-            liveMemtableIndexMap.clear();
-        if (indexMetrics != null)
-            indexMetrics.release();
+        viewManager.invalidate();
         indexAnalyzerFactory.close();
         if (queryAnalyzerFactory != indexAnalyzerFactory)
-        {
             queryAnalyzerFactory.close();
-        }
+        if (memtableIndexManager != null)
+            memtableIndexManager.invalidate();
+        if (indexMetrics != null)
+            indexMetrics.release();
+        if (columnQueryMetrics != null)
+            columnQueryMetrics.release();
     }
 
     public boolean supports(Operator op)
@@ -422,7 +373,7 @@ public class IndexContext
 
         return Objects.equals(columnMetadata, other.columnMetadata) &&
                (indexType == other.indexType) &&
-               Objects.equals(config, other.config) &&
+               Objects.equals(indexMetadata, other.indexMetadata) &&
                Objects.equals(partitionKeyType, other.partitionKeyType) &&
                Objects.equals(clusteringComparator, other.clusteringComparator);
     }
@@ -430,15 +381,15 @@ public class IndexContext
     @Override
     public int hashCode()
     {
-        return Objects.hash(columnMetadata, indexType, config, partitionKeyType, clusteringComparator);
+        return Objects.hash(columnMetadata, indexType, indexMetadata, partitionKeyType, clusteringComparator);
     }
 
     /**
      * A helper method for constructing consistent log messages for specific column indexes.
-     *
+     * <p>
      * Example: For the index "idx" in keyspace "ks" on table "tb", calling this method with the raw message
      * "Flushing new index segment..." will produce...
-     *
+     * <p>
      * "[ks.tb.idx] Flushing new index segment..."
      *
      * @param message The raw content of a logging message, without information identifying it with an index.
@@ -448,6 +399,99 @@ public class IndexContext
     public String logMessage(String message)
     {
         // Index names are unique only within a keyspace.
-        return String.format("[%s.%s.%s] %s", keyspace, table, config == null ? "?" : config.name, message);
+        return String.format("[%s.%s.%s] %s", keyspace, table, indexMetadata == null ? "?" : indexMetadata.name, message);
+    }
+
+    /**
+     * @return the indexes that are built on the given SSTables on the left and corrupted indexes'
+     * corresponding contexts on the right
+     */
+    public Pair<Collection<SSTableIndex>, Collection<SSTableContext>> getBuiltIndexes(Collection<SSTableContext> sstableContexts, boolean validate)
+    {
+        Set<SSTableIndex> valid = new HashSet<>(sstableContexts.size());
+        Set<SSTableContext> invalid = new HashSet<>();
+
+        for (SSTableContext sstableContext : sstableContexts)
+        {
+            if (sstableContext.sstable.isMarkedCompacted())
+                continue;
+
+            if (!sstableContext.indexDescriptor.isPerColumnIndexBuildComplete(this))
+            {
+                logger.debug(logMessage("An on-disk index build for SSTable {} has not completed."), sstableContext.descriptor());
+                continue;
+            }
+
+            if (sstableContext.indexDescriptor.isIndexEmpty(this))
+            {
+                logger.debug(logMessage("No on-disk index was built for SSTable {} because the SSTable " +
+                                        "had no indexable rows for the index."), sstableContext.descriptor());
+                continue;
+            }
+
+            try
+            {
+                if (validate)
+                {
+                    if (!sstableContext.indexDescriptor.validatePerIndexComponents(this))
+                    {
+                        logger.warn(logMessage("Invalid per-column component for SSTable {}"), sstableContext.descriptor());
+                        invalid.add(sstableContext);
+                        continue;
+                    }
+                }
+
+                SSTableIndex index = sstableContext.newSSTableIndex(this);
+                logger.debug(logMessage("Successfully created index for SSTable {}."), sstableContext.descriptor());
+
+                // Try to add new index to the set, if set already has such index, we'll simply release and move on.
+                // This covers situation when SSTable collection has the same SSTable multiple
+                // times because we don't know what kind of collection it actually is.
+                if (!valid.add(index))
+                {
+                    index.release();
+                }
+            }
+            catch (Throwable e)
+            {
+                logger.warn(logMessage("Failed to update per-column components for SSTable {}"), sstableContext.descriptor(), e);
+                invalid.add(sstableContext);
+            }
+        }
+
+        return Pair.create(valid, invalid);
+    }
+
+    /**
+     * @return the number of indexed rows in this index (aka. a pair of term and rowId)
+     */
+    public long getCellCount()
+    {
+        return getView().getIndexes()
+                        .stream()
+                        .mapToLong(SSTableIndex::getRowCount)
+                        .sum();
+    }
+
+    /**
+     * @return the total size (in bytes) of per-column index components
+     */
+    public long diskUsage()
+    {
+        return getView().getIndexes()
+                        .stream()
+                        .mapToLong(SSTableIndex::sizeOfPerColumnComponents)
+                        .sum();
+    }
+
+    /**
+     * @return the total memory usage (in bytes) of per-column index on-disk data structure
+     */
+    public long indexFileCacheSize()
+    {
+        return getView().getIndexes()
+                        .stream()
+                        .mapToLong(SSTableIndex::indexFileCacheSize)
+                        .sum();
     }
 }
diff --git a/src/java/org/apache/cassandra/index/sai/QueryContext.java b/src/java/org/apache/cassandra/index/sai/QueryContext.java
index 4fff4160d3..4996acbd97 100644
--- a/src/java/org/apache/cassandra/index/sai/QueryContext.java
+++ b/src/java/org/apache/cassandra/index/sai/QueryContext.java
@@ -40,10 +40,17 @@ public class QueryContext
 
     public final long executionQuotaNano;
 
+    public long sstablesHit = 0;
+    public long segmentsHit = 0;
     public long partitionsRead = 0;
     public long rowsFiltered = 0;
 
-    public long queryTimeouts = 0;
+    public long trieSegmentsHit = 0;
+
+    public long triePostingsSkips = 0;
+    public long triePostingsDecodes = 0;
+
+    public boolean queryTimedOut = false;
 
     public QueryContext(ReadCommand readCommand, long executionQuotaMs)
     {
@@ -61,7 +68,7 @@ public class QueryContext
     {
         if (totalQueryTimeNs() >= executionQuotaNano && !DISABLE_TIMEOUT)
         {
-            queryTimeouts++;
+            queryTimedOut = true;
             throw new QueryCancelledException(readCommand);
         }
     }
diff --git a/src/java/org/apache/cassandra/index/sai/SSTableContext.java b/src/java/org/apache/cassandra/index/sai/SSTableContext.java
new file mode 100644
index 0000000000..d0ff6372b6
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/SSTableContext.java
@@ -0,0 +1,190 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai;
+
+import java.util.Collections;
+
+import com.google.common.base.Objects;
+
+import org.apache.cassandra.index.sai.disk.PrimaryKeyMap;
+import org.apache.cassandra.index.sai.disk.SSTableIndex;
+import org.apache.cassandra.index.sai.disk.format.IndexDescriptor;
+import org.apache.cassandra.io.sstable.Descriptor;
+import org.apache.cassandra.io.sstable.format.SSTableReader;
+import org.apache.cassandra.utils.Throwables;
+import org.apache.cassandra.utils.concurrent.Ref;
+import org.apache.cassandra.utils.concurrent.RefCounted;
+import org.apache.cassandra.utils.concurrent.SharedCloseableImpl;
+
+/**
+ * An {@link SSTableContext} is created for an individual sstable and is shared across column indexes to track per-sstable
+ * index files.
+ * <p>
+ * The {@link SSTableContext} will be released when receiving a sstable removed notification, but its shared copies in
+ * individual {@link SSTableIndex}es will be released when in-flight read requests complete.
+ */
+public class SSTableContext extends SharedCloseableImpl
+{
+    public final SSTableReader sstable;
+    public final IndexDescriptor indexDescriptor;
+    public final PrimaryKeyMap.Factory primaryKeyMapFactory;
+
+    private SSTableContext(SSTableReader sstable,
+                           IndexDescriptor indexDescriptor,
+                           PrimaryKeyMap.Factory primaryKeyMapFactory,
+                           Cleanup cleanup)
+    {
+        super(cleanup);
+        this.sstable = sstable;
+        this.indexDescriptor = indexDescriptor;
+        this.primaryKeyMapFactory = primaryKeyMapFactory;
+    }
+
+    private SSTableContext(SSTableContext copy)
+    {
+        super(copy);
+        this.sstable = copy.sstable;
+        this.indexDescriptor = copy.indexDescriptor;
+        this.primaryKeyMapFactory = copy.primaryKeyMapFactory;
+    }
+
+    @SuppressWarnings({"resource", "RedundantSuppression"})
+    public static SSTableContext create(SSTableReader sstable)
+    {
+        Ref<? extends SSTableReader> sstableRef = null;
+        PrimaryKeyMap.Factory primaryKeyMapFactory = null;
+
+        IndexDescriptor indexDescriptor = IndexDescriptor.create(sstable);
+        try
+        {
+            sstableRef = sstable.tryRef();
+
+            if (sstableRef == null)
+            {
+                throw new IllegalStateException("Couldn't acquire reference to the sstable: " + sstable);
+            }
+
+            primaryKeyMapFactory = indexDescriptor.newPrimaryKeyMapFactory(sstable);
+
+            Cleanup cleanup = new Cleanup(primaryKeyMapFactory, indexDescriptor, sstableRef);
+
+            return new SSTableContext(sstable, indexDescriptor, primaryKeyMapFactory, cleanup);
+        }
+        catch (Throwable t)
+        {
+            if (sstableRef != null)
+            {
+                sstableRef.release();
+            }
+
+            throw Throwables.unchecked(Throwables.close(t, Collections.singleton(primaryKeyMapFactory)));
+        }
+    }
+
+    @Override
+    public SSTableContext sharedCopy()
+    {
+        return new SSTableContext(this);
+    }
+
+    /**
+     * Returns a new {@link SSTableIndex} for a per-column index
+     */
+    public SSTableIndex newSSTableIndex(IndexContext indexContext)
+    {
+        return indexDescriptor.newSSTableIndex(this, indexContext);
+    }
+
+    /**
+     * @return descriptor of attached sstable
+     */
+    public Descriptor descriptor()
+    {
+        return sstable.descriptor;
+    }
+
+    /**
+     * @return disk usage (in bytes) of per-sstable index files
+     */
+    public long diskUsage()
+    {
+        return indexDescriptor.sizeOnDiskOfPerSSTableComponents();
+    }
+
+    /**
+     * @return number of open files per {@link SSTableContext} instance
+     */
+    public int openFilesPerSSTable()
+    {
+        return indexDescriptor.version.onDiskFormat().openFilesPerSSTableIndex();
+    }
+
+    @Override
+    public String toString()
+    {
+        return "SSTableContext{" +
+               "sstable=" + sstable.descriptor +
+               '}';
+    }
+
+    @Override
+    public boolean equals(Object o)
+    {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        SSTableContext that = (SSTableContext) o;
+        return Objects.equal(sstable.descriptor, that.sstable.descriptor);
+    }
+
+    @Override
+    public int hashCode()
+    {
+        return Objects.hashCode(sstable.descriptor.hashCode());
+    }
+
+    private static class Cleanup implements RefCounted.Tidy
+    {
+        private final PrimaryKeyMap.Factory primaryKeyMapFactory;
+        private final IndexDescriptor indexDescriptor;
+        private final Ref<? extends SSTableReader> sstableRef;
+
+        private Cleanup(PrimaryKeyMap.Factory primaryKeyMapFactory,
+                        IndexDescriptor indexDescriptor,
+                        Ref<? extends SSTableReader> sstableRef)
+        {
+            this.primaryKeyMapFactory = primaryKeyMapFactory;
+            this.indexDescriptor = indexDescriptor;
+            this.sstableRef = sstableRef;
+        }
+
+        @Override
+        public void tidy()
+        {
+            Throwable t = sstableRef.ensureReleased(null);
+            t = Throwables.close(t, Collections.singleton(primaryKeyMapFactory));
+
+            Throwables.maybeFail(t);
+        }
+
+        @Override
+        public String name()
+        {
+            return indexDescriptor.toString();
+        }
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/SSTableContextManager.java b/src/java/org/apache/cassandra/index/sai/SSTableContextManager.java
new file mode 100644
index 0000000000..ba65806e86
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/SSTableContextManager.java
@@ -0,0 +1,148 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai;
+
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import javax.annotation.concurrent.ThreadSafe;
+
+import com.google.common.annotations.VisibleForTesting;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.cassandra.index.sai.disk.format.IndexDescriptor;
+import org.apache.cassandra.io.sstable.format.SSTableReader;
+import org.apache.cassandra.utils.Pair;
+
+/**
+ * Manages per-sstable {@link SSTableContext}s for {@link StorageAttachedIndexGroup}
+ */
+@ThreadSafe
+public class SSTableContextManager
+{
+    private static final Logger logger = LoggerFactory.getLogger(SSTableContextManager.class);
+
+    private final ConcurrentHashMap<SSTableReader, SSTableContext> sstableContexts = new ConcurrentHashMap<>();
+
+    /**
+     * Initialize {@link SSTableContext}s if they are not already initialized.
+     *
+     * @param removed SSTables being removed
+     * @param added SSTables being added
+     * @param validate if true, header and footer will be validated.
+     *
+     * @return a set of contexts for SSTables with valid per-SSTable components, and a set of
+     * SSTables with invalid or missing components
+     */
+    public Pair<Set<SSTableContext>, Set<SSTableReader>> update(Collection<SSTableReader> removed, Iterable<SSTableReader> added, boolean validate)
+    {
+        release(removed);
+
+        Set<SSTableContext> contexts = new HashSet<>();
+        Set<SSTableReader> invalid = new HashSet<>();
+
+        for (SSTableReader sstable : added)
+        {
+            if (sstable.isMarkedCompacted())
+            {
+                continue;
+            }
+
+            IndexDescriptor indexDescriptor = IndexDescriptor.create(sstable);
+
+            if (!indexDescriptor.isPerSSTableIndexBuildComplete())
+            {
+                // Don't even try to validate or add the context if the completion marker is missing.
+                continue;
+            }
+
+            try
+            {
+                // Only validate on restart or newly refreshed SSTable. Newly built files are unlikely to be corrupted.
+                if (validate && !sstableContexts.containsKey(sstable) && !indexDescriptor.validatePerSSTableComponents())
+                {
+                    logger.warn(indexDescriptor.logMessage("Invalid per-SSTable component for SSTable {}"), sstable.descriptor);
+                    invalid.add(sstable);
+                    removeInvalidSSTableContext(sstable);
+                    continue;
+                }
+                // ConcurrentHashMap#computeIfAbsent guarantees atomicity, so {@link SSTableContext#create(SSTableReader)}}
+                // is called at most once per key.
+                contexts.add(sstableContexts.computeIfAbsent(sstable, SSTableContext::create));
+            }
+            catch (Throwable t)
+            {
+                logger.warn(indexDescriptor.logMessage("Failed to update per-SSTable components for SSTable {}"), sstable.descriptor, t);
+                invalid.add(sstable);
+                removeInvalidSSTableContext(sstable);
+            }
+        }
+
+        return Pair.create(contexts, invalid);
+    }
+
+    public void release(Collection<SSTableReader> toRelease)
+    {
+        toRelease.stream().map(sstableContexts::remove).filter(Objects::nonNull).forEach(SSTableContext::close);
+    }
+
+    /**
+     * @return total number of per-sstable open files for live sstables
+     */
+    int openFiles()
+    {
+        return sstableContexts.values().stream().mapToInt(SSTableContext::openFilesPerSSTable).sum();
+    }
+
+    /**
+     * @return total disk usage (in bytes) of all per-sstable index files
+     */
+    long diskUsage()
+    {
+        return sstableContexts.values().stream().mapToLong(SSTableContext::diskUsage).sum();
+    }
+
+    Set<SSTableReader> sstables()
+    {
+        return sstableContexts.keySet();
+    }
+
+    @VisibleForTesting
+    public int size()
+    {
+        return sstableContexts.size();
+    }
+
+    @VisibleForTesting
+    public void clear()
+    {
+        sstableContexts.values().forEach(SSTableContext::close);
+        sstableContexts.clear();
+    }
+
+    @SuppressWarnings("EmptyTryBlock")
+    private void removeInvalidSSTableContext(SSTableReader sstable)
+    {
+        try (SSTableContext ignored = sstableContexts.remove(sstable))
+        {
+        }
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/StorageAttachedIndex.java b/src/java/org/apache/cassandra/index/sai/StorageAttachedIndex.java
index 7e9b96f20f..fdc1759cc5 100644
--- a/src/java/org/apache/cassandra/index/sai/StorageAttachedIndex.java
+++ b/src/java/org/apache/cassandra/index/sai/StorageAttachedIndex.java
@@ -18,21 +18,35 @@
 
 package org.apache.cassandra.index.sai;
 
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
 import java.util.Map;
+import java.util.NavigableMap;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
+import java.util.SortedMap;
+import java.util.TreeMap;
 import java.util.concurrent.Callable;
 import java.util.concurrent.CompletableFuture; // checkstyle: permit this import
 import java.util.concurrent.Future;
+import java.util.function.BooleanSupplier;
+import java.util.stream.Collectors;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.util.concurrent.Futures; // checkstyle: permit this import
+import com.google.common.util.concurrent.ListenableFuture; // checkstyle: permit this import
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import org.apache.cassandra.config.DatabaseDescriptor;
 import org.apache.cassandra.cql3.CQL3Type;
 import org.apache.cassandra.cql3.Operator;
 import org.apache.cassandra.cql3.statements.schema.IndexTarget;
@@ -42,7 +56,7 @@ import org.apache.cassandra.db.DecoratedKey;
 import org.apache.cassandra.db.ReadCommand;
 import org.apache.cassandra.db.RegularAndStaticColumns;
 import org.apache.cassandra.db.WriteContext;
-import org.apache.cassandra.db.compaction.CompactionInfo;
+import org.apache.cassandra.db.compaction.CompactionManager;
 import org.apache.cassandra.db.filter.RowFilter;
 import org.apache.cassandra.db.lifecycle.LifecycleNewTracker;
 import org.apache.cassandra.db.marshal.AbstractType;
@@ -60,14 +74,21 @@ import org.apache.cassandra.index.IndexRegistry;
 import org.apache.cassandra.index.SecondaryIndexBuilder;
 import org.apache.cassandra.index.TargetParser;
 import org.apache.cassandra.index.sai.analyzer.AbstractAnalyzer;
+import org.apache.cassandra.index.sai.disk.SSTableIndex;
+import org.apache.cassandra.index.sai.disk.format.IndexDescriptor;
+import org.apache.cassandra.index.sai.disk.format.Version;
 import org.apache.cassandra.index.sai.utils.TypeUtil;
+import org.apache.cassandra.index.sai.view.View;
 import org.apache.cassandra.index.transactions.IndexTransaction;
+import org.apache.cassandra.io.sstable.Component;
 import org.apache.cassandra.io.sstable.Descriptor;
 import org.apache.cassandra.io.sstable.SSTableFlushObserver;
+import org.apache.cassandra.io.sstable.SSTableIdFactory;
 import org.apache.cassandra.io.sstable.format.SSTableReader;
 import org.apache.cassandra.schema.ColumnMetadata;
 import org.apache.cassandra.schema.IndexMetadata;
 import org.apache.cassandra.schema.TableMetadata;
+import org.apache.cassandra.service.StorageService;
 import org.apache.cassandra.utils.Pair;
 import org.apache.cassandra.utils.concurrent.OpOrder;
 
@@ -83,20 +104,34 @@ public class StorageAttachedIndex implements Index
                                                        Collection<SSTableReader> sstablesToRebuild,
                                                        boolean isFullRebuild)
         {
-            // Return an empty builder for in-memory implementation
-            return new SecondaryIndexBuilder()
-            {
-                @Override
-                public void build()
-                {
-                }
-
-                @Override
-                public CompactionInfo getCompactionInfo()
-                {
-                    return null;
-                }
-            };
+            NavigableMap<SSTableReader, Set<StorageAttachedIndex>> sstables = new TreeMap<>(Comparator.comparing(s -> s.descriptor.id, SSTableIdFactory.COMPARATOR));
+            StorageAttachedIndexGroup group = StorageAttachedIndexGroup.getIndexGroup(cfs);
+
+            assert group != null : "Index group does not exist for table " + cfs.keyspace + '.' + cfs.name;
+
+            indexes.stream()
+                   .filter((i) -> i instanceof StorageAttachedIndex)
+                   .forEach((i) ->
+                            {
+                                StorageAttachedIndex sai = (StorageAttachedIndex) i;
+                                IndexContext indexContext = ((StorageAttachedIndex) i).getIndexContext();
+
+                                // If this is not a full manual index rebuild we can skip SSTables that already have an
+                                // attached index. Otherwise, we override any pre-existent index.
+                                Collection<SSTableReader> ss = sstablesToRebuild;
+                                if (!isFullRebuild)
+                                {
+                                    ss = sstablesToRebuild.stream()
+                                                          .filter(s -> !IndexDescriptor.create(s).isPerColumnIndexBuildComplete(indexContext))
+                                                          .collect(Collectors.toList());
+                                }
+
+                                group.dropIndexSSTables(ss, sai);
+
+                                ss.forEach(sstable -> sstables.computeIfAbsent(sstable, ignore -> new HashSet<>()).add(sai));
+                            });
+
+            return new StorageAttachedIndexBuilder(group, sstables, isFullRebuild, false);
         }
     }
 
@@ -119,6 +154,12 @@ public class StorageAttachedIndex implements Index
     private final ColumnFamilyStore baseCfs;
     private final IndexContext indexContext;
 
+    // Tracks whether we've started the index build on initialization.
+    private volatile boolean initBuildStarted = false;
+
+    // Tracks whether the index has been invalidated due to removal, a table drop, etc.
+    private volatile boolean valid = true;
+
     public StorageAttachedIndex(ColumnFamilyStore baseCfs, IndexMetadata config)
     {
         this.baseCfs = baseCfs;
@@ -229,17 +270,96 @@ public class StorageAttachedIndex implements Index
     {
         // New storage-attached indexes will be available for queries after on disk index data are built.
         // Memtable data will be indexed via flushing triggered by schema change
-        return () -> startInitialBuild(baseCfs).get();
+        // We only want to validate the index files if we are starting up
+        return () -> startInitialBuild(baseCfs, StorageService.instance.isStarting()).get();
     }
 
-    private Future<?> startInitialBuild(ColumnFamilyStore baseCfs)
+    private Future<?> startInitialBuild(ColumnFamilyStore baseCfs, boolean validate)
     {
         if (baseCfs.indexManager.isIndexQueryable(this))
         {
-            logger.debug(indexContext.logMessage("Skipping validation and building in initialization task, as pre-join has already made the storage attached index queryable..."));
+            logger.debug(indexContext.logMessage("Skipping validation and building in initialization task, as pre-join has already made the storage-attached index queryable..."));
+            initBuildStarted = true;
+            return CompletableFuture.completedFuture(null);
+        }
+
+        // stop in-progress compaction tasks to prevent compacted sstable not being indexed.
+        logger.debug(indexContext.logMessage("Stopping active compactions to make sure all sstables are indexed after initial build."));
+        CompactionManager.instance.interruptCompactionFor(Collections.singleton(baseCfs.metadata()),
+                                                          ssTableReader -> true,
+                                                          true);
+
+        // Force another flush to make sure on disk index is generated for memtable data before marking it queryable.
+        // In the case of offline scrub, there are no live memtables.
+        if (!baseCfs.getTracker().getView().liveMemtables.isEmpty())
+        {
+            baseCfs.forceBlockingFlush(ColumnFamilyStore.FlushReason.INDEX_BUILD_STARTED);
+        }
+
+        // It is now safe to flush indexes directly from flushing Memtables.
+        initBuildStarted = true;
+
+        StorageAttachedIndexGroup indexGroup = StorageAttachedIndexGroup.getIndexGroup(baseCfs);
+
+        assert indexGroup != null : "Index group does not exist for table " + baseCfs.keyspace + '.' + baseCfs.name;
+
+        List<SSTableReader> nonIndexed = findNonIndexedSSTables(baseCfs, indexGroup, validate);
+
+        if (nonIndexed.isEmpty())
+        {
+            return CompletableFuture.completedFuture(null);
+        }
+
+        // split sorted sstables into groups with similar size and build each group in separate compaction thread
+        List<List<SSTableReader>> groups = groupBySize(nonIndexed, DatabaseDescriptor.getConcurrentIndexBuilders());
+        List<ListenableFuture<?>> futures = new ArrayList<>();
+
+        for (List<SSTableReader> group : groups)
+        {
+            SortedMap<SSTableReader, Set<StorageAttachedIndex>> current = new TreeMap<>(Comparator.comparing(s -> s.descriptor.id, SSTableIdFactory.COMPARATOR));
+            group.forEach(sstable -> current.put(sstable, Collections.singleton(this)));
+
+            futures.add(CompactionManager.instance.submitIndexBuild(new StorageAttachedIndexBuilder(indexGroup, current, false, true)));
+        }
+
+        logger.info(indexContext.logMessage("Submitting {} parallel initial index builds over {} total sstables..."), futures.size(), nonIndexed.size());
+        return Futures.allAsList(futures);
+    }
+
+    /**
+     * Splits SSTables into groups of similar overall size.
+     *
+     * @param toRebuild a list of SSTables to split (Note that this list will be sorted in place!)
+     * @param parallelism an upper bound on the number of groups
+     *
+     * @return a {@link List} of SSTable groups, each represented as a {@link List} of {@link SSTableReader}
+     */
+    @VisibleForTesting
+    public static List<List<SSTableReader>> groupBySize(List<SSTableReader> toRebuild, int parallelism)
+    {
+        List<List<SSTableReader>> groups = new ArrayList<>();
+
+        toRebuild.sort(Comparator.comparingLong(SSTableReader::onDiskLength).reversed());
+        Iterator<SSTableReader> sortedSSTables = toRebuild.iterator();
+        double dataPerCompactor = toRebuild.stream().mapToLong(SSTableReader::onDiskLength).sum() * 1.0 / parallelism;
+
+        while (sortedSSTables.hasNext())
+        {
+            long sum = 0;
+            List<SSTableReader> current = new ArrayList<>();
+
+            while (sortedSSTables.hasNext() && sum < dataPerCompactor)
+            {
+                SSTableReader sstable = sortedSSTables.next();
+                sum += sstable.onDiskLength();
+                current.add(sstable);
+            }
+
+            assert !current.isEmpty();
+            groups.add(current);
         }
-        baseCfs.indexManager.makeIndexQueryable(this, Status.BUILD_SUCCEEDED);
-        return CompletableFuture.completedFuture(null);
+
+        return groups;
     }
 
     @Override
@@ -259,6 +379,14 @@ public class StorageAttachedIndex implements Index
     {
         return () ->
         {
+            // mark index as invalid, in-progress SSTableIndexWriters will abort
+            valid = false;
+
+            // in case of dropping table, SSTable indexes should already been removed by SSTableListChangedNotification.
+            Set<Component> toRemove = getComponents();
+            for (SSTableIndex sstableIndex : indexContext.getView().getIndexes())
+                sstableIndex.getSSTable().unregisterComponents(toRemove, baseCfs.getTracker());
+
             indexContext.invalidate();
             return null;
         };
@@ -277,14 +405,38 @@ public class StorageAttachedIndex implements Index
         return this::startPreJoinTask;
     }
 
+    public boolean isInitBuildStarted()
+    {
+        return initBuildStarted;
+    }
+
+    public BooleanSupplier isIndexValid()
+    {
+        return () -> valid;
+    }
+
     private Future<?> startPreJoinTask()
     {
         try
         {
             if (baseCfs.indexManager.isIndexQueryable(this))
+            {
                 logger.debug(indexContext.logMessage("Skipping validation in pre-join task, as the initialization task has already made the index queryable..."));
+                baseCfs.indexManager.makeIndexQueryable(this, Status.BUILD_SUCCEEDED);
+                return null;
+            }
+
+            StorageAttachedIndexGroup indexGroup = StorageAttachedIndexGroup.getIndexGroup(baseCfs);
+
+            assert indexGroup != null : "Index group does not exist for table";
 
-            baseCfs.indexManager.makeIndexQueryable(this, Status.BUILD_SUCCEEDED);
+            Collection<SSTableReader> nonIndexed = findNonIndexedSSTables(baseCfs, indexGroup, true);
+
+            if (nonIndexed.isEmpty())
+            {
+                // If the index is complete, mark it queryable before the node starts accepting requests:
+                baseCfs.indexManager.makeIndexQueryable(this, Status.BUILD_SUCCEEDED);
+            }
         }
         catch (Throwable t)
         {
@@ -358,6 +510,40 @@ public class StorageAttachedIndex implements Index
     public void validate(PartitionUpdate update) throws InvalidRequestException
     {}
 
+    /**
+     * This method is called by the startup tasks to find SSTables that don't have indexes. The method is
+     * synchronized so that the view is unchanged between validation and the selection of non-indexed SSTables.
+     *
+     * @return a list SSTables without attached indexes
+     */
+    private synchronized List<SSTableReader> findNonIndexedSSTables(ColumnFamilyStore baseCfs, StorageAttachedIndexGroup group, boolean validate)
+    {
+        Set<SSTableReader> sstables = baseCfs.getLiveSSTables();
+
+        // Initialize the SSTable indexes w/ valid existing components...
+        assert group != null : "Missing index group on " + baseCfs.name;
+        group.onSSTableChanged(Collections.emptyList(), sstables, Collections.singleton(this), validate);
+
+        // ...then identify and rebuild the SSTable indexes that are missing.
+        List<SSTableReader> nonIndexed = new ArrayList<>();
+        View view = indexContext.getView();
+
+        for (SSTableReader sstable : sstables)
+        {
+            // An SSTable is considered not indexed if:
+            //   1. The current view does not contain the SSTable
+            //   2. The SSTable is not marked compacted
+            //   3. The column index does not have a completion marker
+            if (!view.containsSSTable(sstable) && !sstable.isMarkedCompacted() &&
+                !IndexDescriptor.create(sstable).isPerColumnIndexBuildComplete(indexContext))
+            {
+                nonIndexed.add(sstable);
+            }
+        }
+
+        return nonIndexed;
+    }
+
     @Override
     public Searcher searcherFor(ReadCommand command) throws InvalidRequestException
     {
@@ -372,6 +558,16 @@ public class StorageAttachedIndex implements Index
         throw new UnsupportedOperationException("Storage-attached index flush observers should never be created directly.");
     }
 
+    @Override
+    public Set<Component> getComponents()
+    {
+        return Version.LATEST.onDiskFormat()
+                             .perColumnIndexComponents(indexContext)
+                             .stream()
+                             .map(c -> Version.LATEST.makePerIndexComponent(c, indexContext))
+                             .collect(Collectors.toSet());
+    }
+
     @Override
     public Indexer indexerFor(DecoratedKey key,
                               RegularAndStaticColumns columns,
@@ -432,7 +628,8 @@ public class StorageAttachedIndex implements Index
         @Override
         public void insertRow(Row row)
         {
-            adjustMemtableSize(indexContext.index(key, row, memtable), CassandraWriteContext.fromContext(writeContext).getGroup());
+            adjustMemtableSize(indexContext.getMemtableIndexManager().index(key, row, memtable),
+                               CassandraWriteContext.fromContext(writeContext).getGroup());
         }
 
         @Override
diff --git a/src/java/org/apache/cassandra/index/sai/StorageAttachedIndexBuilder.java b/src/java/org/apache/cassandra/index/sai/StorageAttachedIndexBuilder.java
new file mode 100644
index 0000000000..8908b83df0
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/StorageAttachedIndexBuilder.java
@@ -0,0 +1,352 @@
+/*
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *
+ */
+
+package org.apache.cassandra.index.sai;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.stream.Collectors;
+
+import com.google.common.collect.Maps;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.cassandra.db.DecoratedKey;
+import org.apache.cassandra.db.compaction.CompactionInfo;
+import org.apache.cassandra.db.compaction.CompactionInterruptedException;
+import org.apache.cassandra.db.compaction.OperationType;
+import org.apache.cassandra.db.lifecycle.LifecycleTransaction;
+import org.apache.cassandra.db.lifecycle.Tracker;
+import org.apache.cassandra.index.SecondaryIndexBuilder;
+import org.apache.cassandra.index.sai.disk.StorageAttachedIndexWriter;
+import org.apache.cassandra.index.sai.disk.format.IndexDescriptor;
+import org.apache.cassandra.io.sstable.Descriptor;
+import org.apache.cassandra.io.sstable.KeyIterator;
+import org.apache.cassandra.io.sstable.SSTableIdentityIterator;
+import org.apache.cassandra.io.sstable.SSTableFlushObserver;
+import org.apache.cassandra.io.sstable.format.SSTableReader;
+import org.apache.cassandra.io.util.RandomAccessReader;
+import org.apache.cassandra.schema.TableMetadata;
+import org.apache.cassandra.utils.ByteBufferUtil;
+import org.apache.cassandra.utils.Throwables;
+import org.apache.cassandra.utils.TimeUUID;
+import org.apache.cassandra.utils.concurrent.CountDownLatch;
+import org.apache.cassandra.utils.concurrent.Ref;
+
+import static org.apache.cassandra.utils.TimeUUID.Generator.nextTimeUUID;
+
+/**
+ * Multiple storage-attached indexes can start building concurrently. We need to make sure:
+ * 1. Per-SSTable index files are built only once
+ *      a. Per-SSTable index files already built, do nothing
+ *      b. Per-SSTable index files are currently building, we need to wait until it's built in order to consider index built.
+ * 2. Per-column index files are built for each column index
+ */
+public class StorageAttachedIndexBuilder extends SecondaryIndexBuilder
+{
+    protected static final Logger logger = LoggerFactory.getLogger(StorageAttachedIndexBuilder.class);
+
+    // make sure only one builder can write to per sstable files when multiple storage-attached indexes are created simultaneously.
+    private static final Map<SSTableReader, CountDownLatch> inProgress = Maps.newConcurrentMap();
+
+    private final StorageAttachedIndexGroup group;
+    private final TableMetadata metadata;
+    private final Tracker tracker;
+    private final TimeUUID compactionId = nextTimeUUID();
+    private final boolean isFullRebuild;
+    private final boolean isInitialBuild;
+
+    private final SortedMap<SSTableReader, Set<StorageAttachedIndex>> sstables;
+
+    private long bytesProcessed = 0;
+    private final long totalSizeInBytes;
+
+    StorageAttachedIndexBuilder(StorageAttachedIndexGroup group,
+                                SortedMap<SSTableReader, Set<StorageAttachedIndex>> sstables,
+                                boolean isFullRebuild,
+                                boolean isInitialBuild)
+    {
+        this.group = group;
+        this.metadata = group.metadata();
+        this.sstables = sstables;
+        this.tracker = group.table().getTracker();
+        this.isFullRebuild = isFullRebuild;
+        this.isInitialBuild = isInitialBuild;
+        this.totalSizeInBytes = sstables.keySet().stream().mapToLong(SSTableReader::uncompressedLength).sum();
+    }
+
+    @Override
+    public void build()
+    {
+        logger.debug(logMessage("Starting full index build"));
+        for (Map.Entry<SSTableReader, Set<StorageAttachedIndex>> e : sstables.entrySet())
+        {
+            SSTableReader sstable = e.getKey();
+            Set<StorageAttachedIndex> indexes = e.getValue();
+
+            Set<StorageAttachedIndex> existing = validateIndexes(indexes, sstable.descriptor);
+            if (existing.isEmpty())
+            {
+                logger.debug(logMessage("{} dropped during index build"), indexes);
+                continue;
+            }
+
+            if (indexSSTable(sstable, existing))
+            {
+                return;
+            }
+        }
+    }
+
+    private String logMessage(String message)
+    {
+        return String.format("[%s.%s.*] %s", metadata.keyspace, metadata.name, message);
+    }
+
+    /**
+     * @return true if index build should be stopped
+     */
+    private boolean indexSSTable(SSTableReader sstable, Set<StorageAttachedIndex> indexes)
+    {
+        logger.debug(logMessage("Starting index build on {}"), sstable.descriptor);
+
+        CountDownLatch perSSTableFileLock = null;
+        StorageAttachedIndexWriter indexWriter = null;
+
+        Ref<? extends SSTableReader> ref = sstable.tryRef();
+        if (ref == null)
+        {
+            logger.warn(logMessage("Couldn't acquire reference to the SSTable {}. It may have been removed."), sstable.descriptor);
+            return false;
+        }
+
+        try (RandomAccessReader dataFile = sstable.openDataReader();
+             LifecycleTransaction txn = LifecycleTransaction.offline(OperationType.INDEX_BUILD, sstable))
+        {
+            perSSTableFileLock = shouldWritePerSSTableFiles(sstable);
+            // If we were unable to get the per-SSTable file lock it means that the
+            // per-SSTable components are already being built, so we only want to
+            // build the per-index components
+            boolean perIndexComponentsOnly = perSSTableFileLock == null;
+            // remove existing per column index files instead of overwriting
+            IndexDescriptor indexDescriptor = IndexDescriptor.create(sstable);
+            indexes.forEach(index -> indexDescriptor.deleteColumnIndex(index.getIndexContext()));
+
+            indexWriter = StorageAttachedIndexWriter.createBuilderWriter(indexDescriptor, indexes, txn, perIndexComponentsOnly);
+
+            indexWriter.begin();
+
+            long previousBytesRead = 0;
+
+            try (KeyIterator keys = sstable.keyIterator())
+            {
+                while (keys.hasNext())
+                {
+                    if (isStopRequested())
+                    {
+                        logger.debug(indexDescriptor.logMessage("Index build has been stopped"));
+                        throw new CompactionInterruptedException(getCompactionInfo());
+                    }
+
+                    DecoratedKey key = keys.next();
+
+                    indexWriter.startPartition(key, -1, -1);
+
+                    long position = sstable.getPosition(key, SSTableReader.Operator.EQ);
+                    dataFile.seek(position);
+                    ByteBufferUtil.readWithShortLength(dataFile); // key
+
+                    try (SSTableIdentityIterator partition = SSTableIdentityIterator.create(sstable, dataFile, key))
+                    {
+                        // if the row has statics attached, it has to be indexed separately
+                        if (metadata.hasStaticColumns())
+                            indexWriter.nextUnfilteredCluster(partition.staticRow());
+
+                        while (partition.hasNext())
+                            indexWriter.nextUnfilteredCluster(partition.next());
+                    }
+                    long bytesRead = keys.getBytesRead();
+                    bytesProcessed += bytesRead - previousBytesRead;
+                    previousBytesRead = bytesRead;
+                }
+
+                completeSSTable(indexWriter, sstable, indexes, perSSTableFileLock);
+            }
+
+            return false;
+        }
+        catch (Throwable t)
+        {
+            if (indexWriter != null)
+            {
+                indexWriter.abort(t, true);
+            }
+
+            if (t instanceof InterruptedException)
+            {
+                // TODO: Is there anything that makes more sense than just restoring the interrupt?
+                logger.warn(logMessage("Interrupted while building indexes {} on SSTable {}"), indexes, sstable.descriptor);
+                Thread.currentThread().interrupt();
+                return true;
+            }
+            else if (t instanceof CompactionInterruptedException)
+            {
+                //TODO Shouldn't do this if the stop was interrupted by a truncate
+                if (isInitialBuild)
+                {
+                    logger.error(logMessage("Stop requested while building initial indexes {} on SSTable {}."), indexes, sstable.descriptor);
+                    throw Throwables.unchecked(t);
+                }
+                else
+                {
+                    logger.info(logMessage("Stop requested while building indexes {} on SSTable {}."), indexes, sstable.descriptor);
+                    return true;
+                }
+            }
+            else
+            {
+                logger.error(logMessage("Unable to build indexes {} on SSTable {}. Cause: {}."), indexes, sstable, t.getMessage());
+                throw Throwables.unchecked(t);
+            }
+        }
+        finally
+        {
+            ref.release();
+            // release current lock in case of error
+            if (perSSTableFileLock != null)
+            {
+                inProgress.remove(sstable);
+                perSSTableFileLock.decrement();
+            }
+        }
+    }
+
+    @Override
+    public CompactionInfo getCompactionInfo()
+    {
+        return new CompactionInfo(metadata,
+                                  OperationType.INDEX_BUILD,
+                                  bytesProcessed,
+                                  totalSizeInBytes,
+                                  compactionId,
+                                  sstables.keySet());
+    }
+
+    /**
+     * if the per sstable index files are already created, no need to write them again, unless found corrupted on rebuild
+     * if not created, try to acquire a lock, so only one builder will generate per sstable index files
+     */
+    private CountDownLatch shouldWritePerSSTableFiles(SSTableReader sstable)
+    {
+        // if per-table files are incomplete or checksum failed during full rebuild.
+        IndexDescriptor indexDescriptor = IndexDescriptor.create(sstable);
+        if (!indexDescriptor.isPerSSTableIndexBuildComplete() ||
+            (isFullRebuild && !indexDescriptor.validatePerSSTableComponentsChecksum()))
+        {
+            CountDownLatch latch = CountDownLatch.newCountDownLatch(1);
+            if (inProgress.putIfAbsent(sstable, latch) == null)
+            {
+                // lock owner should clean up existing per-SSTable files
+                group.deletePerSSTableFiles(Collections.singleton(sstable));
+                return latch;
+            }
+        }
+        return null;
+    }
+
+    private void completeSSTable(SSTableFlushObserver indexWriter,
+                                 SSTableReader sstable,
+                                 Set<StorageAttachedIndex> indexes,
+                                 CountDownLatch latch) throws InterruptedException
+    {
+        indexWriter.complete();
+
+        if (latch != null)
+        {
+            // current builder owns the lock
+            latch.decrement();
+        }
+        else
+        {
+            /*
+             * When there is no lock, it means the per sstable index files are already created, just proceed to finish.
+             * When there is a lock held by another builder, wait for it to finish before finishing marking current index built.
+             */
+            latch = inProgress.get(sstable);
+            if (latch != null)
+                latch.await();
+        }
+
+        Set<StorageAttachedIndex> existing = validateIndexes(indexes, sstable.descriptor);
+        if (existing.isEmpty())
+        {
+            logger.debug(logMessage("{} dropped during index build"), indexes);
+            return;
+        }
+
+        // register custom index components into existing sstables
+        sstable.registerComponents(StorageAttachedIndexGroup.getLiveComponents(sstable, existing), tracker);
+        Set<StorageAttachedIndex> incomplete = group.onSSTableChanged(Collections.emptyList(), Collections.singleton(sstable), existing, false);
+
+        if (!incomplete.isEmpty())
+        {
+            // If this occurs during an initial index build, there is only one index in play, and
+            // throwing here to terminate makes sense. (This allows the initialization task to fail
+            // correctly and be marked as failed by the SIM.) In other cases, such as rebuilding a
+            // set of indexes for a new added/streamed SSTables, we terminate pessimistically. In
+            // other words, we abort the SSTable index write across all column indexes and mark
+            // then non-queryable until a restart or other incremental rebuild occurs.
+            throw new RuntimeException(logMessage("Failed to update views on column indexes " + incomplete + " on indexes " + indexes + '.'));
+        }
+    }
+
+    /**
+     *  In case of full rebuild, stop the index build if any index is dropped.
+     *  Otherwise, skip dropped indexes to avoid exception during repair/streaming.
+     */
+    private Set<StorageAttachedIndex> validateIndexes(Set<StorageAttachedIndex> indexes, Descriptor descriptor)
+    {
+        Set<StorageAttachedIndex> existing = new HashSet<>();
+        Set<StorageAttachedIndex> dropped = new HashSet<>();
+
+        for (StorageAttachedIndex index : indexes)
+        {
+            if (group.containsIndex(index))
+                existing.add(index);
+            else
+                dropped.add(index);
+        }
+
+        if (!dropped.isEmpty())
+        {
+            String droppedIndexes = dropped.stream().map(sai -> sai.getIndexContext().getIndexName()).collect(Collectors.toList()).toString();
+            if (isFullRebuild)
+                throw new RuntimeException(logMessage(String.format("%s are dropped, will stop index build.", droppedIndexes)));
+            else
+                logger.debug(logMessage("Skip building dropped index {} on sstable {}"), droppedIndexes, descriptor.baseFile());
+        }
+
+        return existing;
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/StorageAttachedIndexGroup.java b/src/java/org/apache/cassandra/index/sai/StorageAttachedIndexGroup.java
index a06bdca59f..6d1d49c483 100644
--- a/src/java/org/apache/cassandra/index/sai/StorageAttachedIndexGroup.java
+++ b/src/java/org/apache/cassandra/index/sai/StorageAttachedIndexGroup.java
@@ -19,6 +19,7 @@ package org.apache.cassandra.index.sai;
 
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.Objects;
 import java.util.Set;
 import java.util.function.Predicate;
@@ -27,8 +28,11 @@ import javax.annotation.Nullable;
 import javax.annotation.concurrent.ThreadSafe;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.primitives.Ints;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import org.apache.cassandra.db.ColumnFamilyStore;
 import org.apache.cassandra.db.DecoratedKey;
@@ -40,6 +44,11 @@ import org.apache.cassandra.db.lifecycle.Tracker;
 import org.apache.cassandra.db.memtable.Memtable;
 import org.apache.cassandra.db.rows.Row;
 import org.apache.cassandra.index.Index;
+import org.apache.cassandra.index.sai.disk.SSTableIndex;
+import org.apache.cassandra.index.sai.disk.StorageAttachedIndexWriter;
+import org.apache.cassandra.index.sai.disk.format.IndexDescriptor;
+import org.apache.cassandra.index.sai.disk.format.Version;
+import org.apache.cassandra.index.sai.metrics.IndexGroupMetrics;
 import org.apache.cassandra.index.sai.metrics.TableQueryMetrics;
 import org.apache.cassandra.index.sai.metrics.TableStateMetrics;
 import org.apache.cassandra.index.sai.plan.StorageAttachedIndexQueryPlan;
@@ -47,11 +56,16 @@ import org.apache.cassandra.index.transactions.IndexTransaction;
 import org.apache.cassandra.io.sstable.Component;
 import org.apache.cassandra.io.sstable.Descriptor;
 import org.apache.cassandra.io.sstable.SSTableFlushObserver;
+import org.apache.cassandra.io.sstable.format.SSTableReader;
 import org.apache.cassandra.notifications.INotification;
 import org.apache.cassandra.notifications.INotificationConsumer;
 import org.apache.cassandra.notifications.MemtableDiscardedNotification;
 import org.apache.cassandra.notifications.MemtableRenewedNotification;
+import org.apache.cassandra.notifications.SSTableAddedNotification;
+import org.apache.cassandra.notifications.SSTableListChangedNotification;
 import org.apache.cassandra.schema.TableMetadata;
+import org.apache.cassandra.utils.Pair;
+import org.apache.cassandra.utils.Throwables;
 
 /**
  * Orchestrates building of storage-attached indices, and manages lifecycle of resources shared between them.
@@ -59,16 +73,23 @@ import org.apache.cassandra.schema.TableMetadata;
 @ThreadSafe
 public class StorageAttachedIndexGroup implements Index.Group, INotificationConsumer
 {
+    private static final Logger logger = LoggerFactory.getLogger(StorageAttachedIndexGroup.class);
+
     private final TableQueryMetrics queryMetrics;
     private final TableStateMetrics stateMetrics;
-    private final Set<Index> indexes = Sets.newConcurrentHashSet();
+    private final IndexGroupMetrics groupMetrics;
+    private final Set<StorageAttachedIndex> indexes = Sets.newConcurrentHashSet();
     private final ColumnFamilyStore baseCfs;
 
+    private final SSTableContextManager contextManager;
+
     StorageAttachedIndexGroup(ColumnFamilyStore baseCfs)
     {
         this.baseCfs = baseCfs;
         this.queryMetrics = new TableQueryMetrics(baseCfs.metadata());
         this.stateMetrics = new TableStateMetrics(baseCfs.metadata(), this);
+        this.groupMetrics = new IndexGroupMetrics(baseCfs.metadata(), this);
+        this.contextManager = new SSTableContextManager();
 
         Tracker tracker = baseCfs.getTracker();
         tracker.subscribe(this);
@@ -83,14 +104,14 @@ public class StorageAttachedIndexGroup implements Index.Group, INotificationCons
     @Override
     public Set<Index> getIndexes()
     {
-        return indexes;
+        return ImmutableSet.copyOf(indexes);
     }
 
     @Override
     public void addIndex(Index index)
     {
         assert index instanceof StorageAttachedIndex;
-        indexes.add(index);
+        indexes.add((StorageAttachedIndex) index);
     }
 
     @Override
@@ -100,10 +121,13 @@ public class StorageAttachedIndexGroup implements Index.Group, INotificationCons
         boolean removed = indexes.remove(index);
         assert removed : "Cannot remove non-existing index " + index;
         /*
-         * For the in-memory implementation we only need to unsubscribe from the tracker
+         * per index files are dropped via {@link StorageAttachedIndex#getInvalidateTask()}
          */
         if (indexes.isEmpty())
         {
+            for (SSTableReader sstable : contextManager.sstables())
+                sstable.unregisterComponents(IndexDescriptor.create(sstable).getLivePerSSTableComponents(), baseCfs.getTracker());
+            deletePerSSTableFiles(baseCfs.getLiveSSTables());
             baseCfs.getTracker().unsubscribe(this);
         }
     }
@@ -113,11 +137,13 @@ public class StorageAttachedIndexGroup implements Index.Group, INotificationCons
     {
         // in case of dropping table, sstable contexts should already been removed by SSTableListChangedNotification.
         queryMetrics.release();
+        groupMetrics.release();
         stateMetrics.release();
         baseCfs.getTracker().unsubscribe(this);
     }
 
     @Override
+    @SuppressWarnings("SuspiciousMethodCalls")
     public boolean containsIndex(Index index)
     {
         return indexes.contains(index);
@@ -165,7 +191,19 @@ public class StorageAttachedIndexGroup implements Index.Group, INotificationCons
     @Override
     public SSTableFlushObserver getFlushObserver(Descriptor descriptor, LifecycleNewTracker tracker, TableMetadata tableMetadata)
     {
-        return null;
+        IndexDescriptor indexDescriptor = IndexDescriptor.create(descriptor, tableMetadata.comparator);
+        try
+        {
+            return StorageAttachedIndexWriter.createFlushObserverWriter(indexDescriptor, indexes, tracker);
+        }
+        catch (Throwable t)
+        {
+            String message = "Unable to create storage-attached index writer on SSTable flush." +
+                             " All indexes from this table are going to be marked as non-queryable and will need to be rebuilt.";
+            logger.error(indexDescriptor.logMessage(message), t);
+            indexes.forEach(StorageAttachedIndex::makeIndexNonQueryable);
+            return null;
+        }
     }
 
     @Override
@@ -181,23 +219,147 @@ public class StorageAttachedIndexGroup implements Index.Group, INotificationCons
         return getComponents(indexes);
     }
 
-    // TODO: Currently returns an empty set for in-memory only implementation
-    static Set<Component> getComponents(Collection<Index> indices)
+    static Set<Component> getComponents(Collection<StorageAttachedIndex> indices)
+    {
+        Set<Component> components = Version.LATEST.onDiskFormat()
+                                                  .perSSTableIndexComponents()
+                                                  .stream()
+                                                  .map(Version.LATEST::makePerSSTableComponent)
+                                                  .collect(Collectors.toSet());
+        indices.forEach(index -> components.addAll(index.getComponents()));
+        return components;
+    }
+
+    // This differs from getComponents in that it only returns index components that exist on disk.
+    // It avoids errors being logged by the SSTable.readTOC method when we have an empty index.
+    @VisibleForTesting
+    public static Set<Component> getLiveComponents(SSTableReader sstable, Collection<StorageAttachedIndex> indices)
     {
-        return Collections.emptySet();
+        IndexDescriptor indexDescriptor = IndexDescriptor.create(sstable);
+        Set<Component> components = indexDescriptor.getLivePerSSTableComponents();
+        indices.forEach(index -> components.addAll(indexDescriptor.getLivePerIndexComponents(index.getIndexContext())));
+        return components;
     }
 
     @Override
     public void handleNotification(INotification notification, Object sender)
     {
-        if (notification instanceof MemtableRenewedNotification)
+        // unfortunately, we can only check the type of notification via instanceof :(
+        if (notification instanceof SSTableAddedNotification)
         {
-            indexes.forEach(index -> ((StorageAttachedIndex)index).getIndexContext().renewMemtable(((MemtableRenewedNotification) notification).renewed));
+            SSTableAddedNotification notice = (SSTableAddedNotification) notification;
+
+            // Avoid validation for index files just written following Memtable flush.
+            boolean validate = !notice.memtable().isPresent();
+            onSSTableChanged(Collections.emptySet(), notice.added, indexes, validate);
+        }
+        else if (notification instanceof SSTableListChangedNotification)
+        {
+            SSTableListChangedNotification notice = (SSTableListChangedNotification) notification;
+
+            // Avoid validation for index files just written during compaction.
+            onSSTableChanged(notice.removed, notice.added, indexes, false);
+        }
+        else if (notification instanceof MemtableRenewedNotification)
+        {
+            indexes.forEach(index -> index.getIndexContext().getMemtableIndexManager().renewMemtable(((MemtableRenewedNotification) notification).renewed));
         }
         else if (notification instanceof MemtableDiscardedNotification)
         {
-            indexes.forEach(index -> ((StorageAttachedIndex)index).getIndexContext().discardMemtable(((MemtableDiscardedNotification) notification).memtable));
+            indexes.forEach(index -> index.getIndexContext().getMemtableIndexManager().discardMemtable(((MemtableDiscardedNotification) notification).memtable));
+        }
+    }
+
+    void deletePerSSTableFiles(Collection<SSTableReader> sstables)
+    {
+        contextManager.release(sstables);
+        sstables.forEach(sstableReader -> IndexDescriptor.create(sstableReader).deletePerSSTableIndexComponents());
+    }
+
+    void dropIndexSSTables(Collection<SSTableReader> ss, StorageAttachedIndex index)
+    {
+        try
+        {
+            index.getIndexContext().drop(ss);
+        }
+        catch (Throwable t)
+        {
+            // Mark the index non-queryable, as its view may be compromised.
+            index.makeIndexNonQueryable();
+
+            throw Throwables.unchecked(t);
+        }
+    }
+
+    /**
+     * This method is synchronized to avoid concurrent initialization tasks validating same per-SSTable files.
+     *
+     * @return the set of column indexes that were marked as non-queryable as a result of their per-SSTable index
+     * files being corrupt or being unable to successfully update their views
+     */
+    synchronized Set<StorageAttachedIndex> onSSTableChanged(Collection<SSTableReader> removed, Iterable<SSTableReader> added,
+                                                            Set<StorageAttachedIndex> indexes, boolean validate)
+    {
+        Pair<Set<SSTableContext>, Set<SSTableReader>> results = contextManager.update(removed, added, validate);
+
+        if (!results.right.isEmpty())
+        {
+            results.right.forEach(sstable -> {
+                IndexDescriptor indexDescriptor = IndexDescriptor.create(sstable);
+                indexDescriptor.deletePerSSTableIndexComponents();
+                // Column indexes are invalid if their SSTable-level components are corrupted so delete
+                // their associated index files and mark them non-queryable.
+                indexes.forEach(index -> {
+                    indexDescriptor.deleteColumnIndex(index.getIndexContext());
+                    index.makeIndexNonQueryable();
+                });
+            });
+            return indexes;
+        }
+
+        Set<StorageAttachedIndex> incomplete = new HashSet<>();
+
+        for (StorageAttachedIndex index : indexes)
+        {
+            Collection<SSTableContext> invalid = index.getIndexContext().onSSTableChanged(removed, results.left, validate);
+
+            if (!invalid.isEmpty())
+            {
+                // Delete the index files and mark the index non-queryable, as its view may be compromised,
+                // and incomplete, for our callers:
+                invalid.forEach(context -> context.indexDescriptor.deleteColumnIndex(index.getIndexContext()));
+                index.makeIndexNonQueryable();
+                incomplete.add(index);
+            }
         }
+        return incomplete;
+    }
+
+    /**
+     * open index files by checking number of {@link SSTableContext} and {@link SSTableIndex},
+     * so transient open files during validation and files that are still open for in-flight requests will not be tracked.
+     *
+     * @return total number of open files for all {@link StorageAttachedIndex}es.
+     */
+    public int openIndexFiles()
+    {
+        return contextManager.openFiles() + indexes.stream().mapToInt(index -> index.getIndexContext().openPerIndexFiles()).sum();
+    }
+
+    /**
+     * @return total disk usage (in bytes) of all per-sstable index files
+     */
+    public long diskUsage()
+    {
+        return contextManager.diskUsage();
+    }
+
+    /**
+     * @return count of indexes building
+     */
+    public int totalIndexBuildsInProgress()
+    {
+        return (int) indexes.stream().filter(i -> baseCfs.indexManager.isIndexBuilding(i.getIndexMetadata().name)).count();
     }
 
     /**
@@ -216,6 +378,15 @@ public class StorageAttachedIndexGroup implements Index.Group, INotificationCons
         return indexes.size();
     }
 
+    /**
+     * @return total disk usage of all per-sstable index files and per-column index files
+     */
+    public long totalDiskUsage()
+    {
+        return diskUsage() + indexes.stream().flatMap(index -> index.getIndexContext().getView().getIndexes().stream())
+                                    .mapToLong(SSTableIndex::sizeOfPerColumnComponents).sum();
+    }
+
     public TableMetadata metadata()
     {
         return baseCfs.metadata();
@@ -226,12 +397,31 @@ public class StorageAttachedIndexGroup implements Index.Group, INotificationCons
         return baseCfs;
     }
 
+    @VisibleForTesting
+    public SSTableContextManager sstableContextManager()
+    {
+        return contextManager;
+    }
+
+    /**
+     * simulate index loading on restart with index file validation
+     */
+    @VisibleForTesting
+    public void unsafeReload()
+    {
+        contextManager.clear();
+        onSSTableChanged(baseCfs.getLiveSSTables(), Collections.emptySet(), indexes, false);
+        onSSTableChanged(Collections.emptySet(), baseCfs.getLiveSSTables(), indexes, true);
+    }
+
     /**
      * Simulate the index going through a restart of node
      */
     @VisibleForTesting
     public void reset()
     {
-        indexes.forEach(i -> ((StorageAttachedIndex) i).makeIndexNonQueryable());
+        contextManager.clear();
+        indexes.forEach(StorageAttachedIndex::makeIndexNonQueryable);
+        onSSTableChanged(baseCfs.getLiveSSTables(), Collections.emptySet(), indexes, false);
     }
 }
diff --git a/src/java/org/apache/cassandra/index/sai/analyzer/AbstractAnalyzer.java b/src/java/org/apache/cassandra/index/sai/analyzer/AbstractAnalyzer.java
index 64a2f2fed5..d1e7bae2fe 100644
--- a/src/java/org/apache/cassandra/index/sai/analyzer/AbstractAnalyzer.java
+++ b/src/java/org/apache/cassandra/index/sai/analyzer/AbstractAnalyzer.java
@@ -28,7 +28,6 @@ import org.apache.cassandra.db.marshal.AbstractType;
 public abstract class AbstractAnalyzer implements Iterator<ByteBuffer>
 {
     protected ByteBuffer next = null;
-    String nextLiteral = null;
 
     /**
      * @return true if index value is transformed, e.g. normalized or lower-cased or tokenized.
@@ -66,7 +65,6 @@ public abstract class AbstractAnalyzer implements Iterator<ByteBuffer>
     public void reset(ByteBuffer input)
     {
         this.next = null;
-        this.nextLiteral = null;
 
         resetInternal(input);
     }
diff --git a/src/java/org/apache/cassandra/index/sai/disk/IndexSearchResultIterator.java b/src/java/org/apache/cassandra/index/sai/disk/IndexSearchResultIterator.java
new file mode 100644
index 0000000000..f10bf5b8ae
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/IndexSearchResultIterator.java
@@ -0,0 +1,141 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.cassandra.db.PartitionPosition;
+import org.apache.cassandra.dht.AbstractBounds;
+import org.apache.cassandra.exceptions.QueryCancelledException;
+import org.apache.cassandra.index.sai.QueryContext;
+import org.apache.cassandra.index.sai.plan.Expression;
+import org.apache.cassandra.index.sai.iterators.KeyRangeIterator;
+import org.apache.cassandra.index.sai.iterators.KeyRangeUnionIterator;
+import org.apache.cassandra.index.sai.utils.PrimaryKey;
+import org.apache.cassandra.io.util.FileUtils;
+import org.apache.cassandra.utils.Throwables;
+
+public class IndexSearchResultIterator extends KeyRangeIterator
+{
+    private static final Logger logger = LoggerFactory.getLogger(IndexSearchResultIterator.class);
+
+    private final QueryContext context;
+    private final KeyRangeIterator union;
+    private final Collection<SSTableIndex> referencedIndexes;
+
+    private IndexSearchResultIterator(KeyRangeIterator union, Collection<SSTableIndex> referencedIndexes, QueryContext queryContext)
+    {
+        super(union.getMinimum(), union.getMaximum(), union.getCount());
+
+        this.union = union;
+        this.referencedIndexes = referencedIndexes;
+        this.context = queryContext;
+    }
+
+    /**
+     * Builds a new {@link IndexSearchResultIterator} that wraps a {@link KeyRangeUnionIterator} over the
+     * results of searching the {@link org.apache.cassandra.index.sai.memory.MemtableIndex} and the {@link SSTableIndex}es.
+     */
+    @SuppressWarnings({"resource", "RedundantSuppression"})
+    public static IndexSearchResultIterator build(Expression expression,
+                                                  Collection<SSTableIndex> sstableIndexes,
+                                                  AbstractBounds<PartitionPosition> keyRange,
+                                                  QueryContext queryContext)
+    {
+        List<KeyRangeIterator> subIterators = new ArrayList<>(1 + sstableIndexes.size());
+
+        KeyRangeIterator memtableIterator = expression.context.getMemtableIndexManager().searchMemtableIndexes(expression, keyRange);
+        if (memtableIterator != null)
+            subIterators.add(memtableIterator);
+
+        for (SSTableIndex sstableIndex : sstableIndexes)
+        {
+            try
+            {
+                queryContext.checkpoint();
+                queryContext.sstablesHit++;
+
+                if (sstableIndex.isReleased())
+                    throw new IllegalStateException(sstableIndex.getIndexContext().logMessage("Index was released from the view during the query"));
+
+                List<KeyRangeIterator> segmentIterators = sstableIndex.search(expression, keyRange, queryContext);
+
+                if (!segmentIterators.isEmpty())
+                    subIterators.addAll(segmentIterators);
+            }
+            catch (Throwable e)
+            {
+                if (!(e instanceof QueryCancelledException))
+                    logger.debug(sstableIndex.getIndexContext().logMessage(String.format("Failed search an index %s, aborting query.", sstableIndex.getSSTable())), e);
+
+                throw Throwables.cleaned(e);
+            }
+        }
+
+        KeyRangeIterator union = KeyRangeUnionIterator.build(subIterators);
+        return new IndexSearchResultIterator(union, sstableIndexes, queryContext);
+    }
+
+    protected PrimaryKey computeNext()
+    {
+        try
+        {
+            return union.hasNext() ? union.next() : endOfData();
+        }
+        finally
+        {
+            context.checkpoint();
+        }
+    }
+
+    protected void performSkipTo(PrimaryKey nextKey)
+    {
+        try
+        {
+            union.skipTo(nextKey);
+        }
+        finally
+        {
+            context.checkpoint();
+        }
+    }
+
+    public void close()
+    {
+        FileUtils.closeQuietly(union);
+        referencedIndexes.forEach(IndexSearchResultIterator::releaseQuietly);
+        referencedIndexes.clear();
+    }
+
+    private static void releaseQuietly(SSTableIndex index)
+    {
+        try
+        {
+            index.release();
+        }
+        catch (Throwable e)
+        {
+            logger.error(index.getIndexContext().logMessage(String.format("Failed to release index on SSTable %s", index.getSSTable())), e);
+        }
+    }
+}
diff --git a/test/unit/org/apache/cassandra/index/sai/memory/InMemoryKeyRangeIteratorTest.java b/src/java/org/apache/cassandra/index/sai/disk/PerColumnIndexWriter.java
similarity index 50%
copy from test/unit/org/apache/cassandra/index/sai/memory/InMemoryKeyRangeIteratorTest.java
copy to src/java/org/apache/cassandra/index/sai/disk/PerColumnIndexWriter.java
index 71db5c03d8..4ce75cbe08 100644
--- a/test/unit/org/apache/cassandra/index/sai/memory/InMemoryKeyRangeIteratorTest.java
+++ b/src/java/org/apache/cassandra/index/sai/disk/PerColumnIndexWriter.java
@@ -15,24 +15,34 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.cassandra.index.sai.memory;
+package org.apache.cassandra.index.sai.disk;
 
-import java.util.Arrays;
-import java.util.SortedSet;
-import java.util.TreeSet;
+import java.io.IOException;
 
+import com.google.common.base.Stopwatch;
+
+import org.apache.cassandra.db.rows.Row;
 import org.apache.cassandra.index.sai.utils.PrimaryKey;
-import org.apache.cassandra.index.sai.utils.KeyRangeIterator;
 
-public class InMemoryKeyRangeIteratorTest extends AbstractInMemoryKeyRangeIteratorTester
+/**
+ * Creates the on-disk components for a given column index.
+ */
+public interface PerColumnIndexWriter
 {
-    @Override
-    protected KeyRangeIterator makeIterator(long minimumTokenValue, long maximumTokenValue, long... tokens)
-    {
-        SortedSet<PrimaryKey> set = new TreeSet<>();
+    /**
+     * Adds a row to this index.
+     */
+    void addRow(PrimaryKey key, Row row, long sstableRowId) throws IOException;
 
-        Arrays.stream(tokens).forEach(t -> set.add(keyForToken(t)));
+    /**
+     * Builds on-disk index data structures from accumulated data, moves them all to the filesystem, and fsync created files.
+     */
+    void complete(Stopwatch stopwatch) throws IOException;
 
-        return new InMemoryKeyRangeIterator(set);
-    }
+    /**
+     * Aborts accumulating data. Allows to clean up resources on error.
+     * 
+     * Note: Implementations should be idempotent, i.e. safe to call multiple times without producing undesirable side-effects.
+     */
+    void abort(Throwable cause);
 }
diff --git a/src/java/org/apache/cassandra/index/sai/virtual/StorageAttachedIndexTables.java b/src/java/org/apache/cassandra/index/sai/disk/PerSSTableIndexWriter.java
similarity index 64%
copy from src/java/org/apache/cassandra/index/sai/virtual/StorageAttachedIndexTables.java
copy to src/java/org/apache/cassandra/index/sai/disk/PerSSTableIndexWriter.java
index 0d4f12678f..09c8a34a9e 100644
--- a/src/java/org/apache/cassandra/index/sai/virtual/StorageAttachedIndexTables.java
+++ b/src/java/org/apache/cassandra/index/sai/disk/PerSSTableIndexWriter.java
@@ -15,21 +15,24 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+package org.apache.cassandra.index.sai.disk;
 
-package org.apache.cassandra.index.sai.virtual;
+import java.io.IOException;
 
-import java.util.Collection;
-import java.util.Collections;
+import org.apache.cassandra.index.sai.utils.PrimaryKey;
 
-import org.apache.cassandra.db.virtual.VirtualTable;
-
-public class StorageAttachedIndexTables
+/**
+ * Writes all SSTable-attached index files. The files written are version specific.
+ */
+public interface PerSSTableIndexWriter
 {
-    private StorageAttachedIndexTables()
+    PerSSTableIndexWriter NONE = (key) -> {};
+
+    void nextRow(PrimaryKey primaryKey) throws IOException;
+
+    default void complete() throws IOException
     {}
 
-    public static Collection<VirtualTable> getAll(String keyspace)
-    {
-        return Collections.singletonList(new IndexesSystemView(keyspace));
-    }
+    default void abort()
+    {}
 }
diff --git a/src/java/org/apache/cassandra/index/sai/disk/PrimaryKeyMap.java b/src/java/org/apache/cassandra/index/sai/disk/PrimaryKeyMap.java
new file mode 100644
index 0000000000..fa47b9f4a4
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/PrimaryKeyMap.java
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.index.sai.disk;
+
+import java.io.Closeable;
+import java.io.IOException;
+
+import javax.annotation.concurrent.NotThreadSafe;
+
+import org.apache.cassandra.index.sai.utils.PrimaryKey;
+
+/**
+ * A bidirectional map of {@link PrimaryKey} to row ID. Implementations of this interface
+ * are not expected to be threadsafe.
+ */
+@NotThreadSafe
+public interface PrimaryKeyMap extends Closeable
+{
+    /**
+     * A factory for creating {@link PrimaryKeyMap} instances. Implementations of this
+     * interface are expected to be threadsafe.
+     */
+    @NotThreadSafe
+    interface Factory extends Closeable
+    {
+        /**
+         * Creates a new {@link PrimaryKeyMap} instance
+         *
+         * @return a {@link PrimaryKeyMap}
+         * @throws IOException if the {@link PrimaryKeyMap} couldn't be created
+         */
+        PrimaryKeyMap newPerSSTablePrimaryKeyMap() throws IOException;
+
+        @Override
+        default void close()
+        {
+        }
+    }
+
+    /**
+     * Returns a {@link PrimaryKey} for a row ID
+     *
+     * @param sstableRowId the row ID to lookup
+     * @return the {@link PrimaryKey} associated with the row ID
+     */
+    PrimaryKey primaryKeyFromRowId(long sstableRowId);
+
+    /**
+     * Returns a row ID for a {@link PrimaryKey}
+     *
+     * @param key the {@link PrimaryKey} to lookup
+     * @return the row ID associated with the {@link PrimaryKey}
+     */
+    long rowIdFromPrimaryKey(PrimaryKey key);
+
+    @Override
+    default void close()
+    {
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/RowMapping.java b/src/java/org/apache/cassandra/index/sai/disk/RowMapping.java
new file mode 100644
index 0000000000..fadbae5540
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/RowMapping.java
@@ -0,0 +1,175 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk;
+
+import java.util.Collections;
+import java.util.Iterator;
+
+import javax.annotation.concurrent.NotThreadSafe;
+
+import com.carrotsearch.hppc.LongArrayList;
+import org.apache.cassandra.db.compaction.OperationType;
+import org.apache.cassandra.db.rows.RangeTombstoneMarker;
+import org.apache.cassandra.db.rows.Row;
+import org.apache.cassandra.db.tries.InMemoryTrie;
+import org.apache.cassandra.index.sai.memory.MemtableIndex;
+import org.apache.cassandra.index.sai.utils.PrimaryKey;
+import org.apache.cassandra.index.sai.utils.PrimaryKeys;
+import org.apache.cassandra.io.compress.BufferType;
+import org.apache.cassandra.utils.AbstractGuavaIterator;
+import org.apache.cassandra.utils.Pair;
+import org.apache.cassandra.utils.bytecomparable.ByteComparable;
+
+/**
+ * In memory representation of {@link PrimaryKey} to row ID mappings which only contains
+ * {@link Row} regardless of whether it's live or deleted. ({@link RangeTombstoneMarker} is not included.)
+ *
+ * While this inherits the threading behaviour of {@link InMemoryTrie} of single-writer / multiple-reader,
+ * since it is only used by {@link StorageAttachedIndexWriter}, which is not threadsafe, we can consider
+ * this class not threadsafe as well.
+ */
+@NotThreadSafe
+public class RowMapping
+{
+    private static final InMemoryTrie.UpsertTransformer<Long, Long> OVERWRITE_TRANSFORMER = (existing, update) -> update;
+
+    public static final RowMapping DUMMY = new RowMapping()
+    {
+        @Override
+        public Iterator<Pair<ByteComparable, LongArrayList>> merge(MemtableIndex index) { return Collections.emptyIterator(); }
+
+        @Override
+        public void complete() {}
+
+        @Override
+        public boolean isComplete()
+        {
+            return true;
+        }
+
+        @Override
+        public void add(PrimaryKey key, long sstableRowId) {}
+    };
+
+    private final InMemoryTrie<Long> rowMapping = new InMemoryTrie<>(BufferType.OFF_HEAP);
+
+    private boolean complete = false;
+
+    public PrimaryKey minKey;
+    public PrimaryKey maxKey;
+
+    public long maxSSTableRowId = -1;
+
+    private RowMapping()
+    {}
+
+    /**
+     * Create row mapping for FLUSH operation only.
+     */
+    public static RowMapping create(OperationType opType)
+    {
+        if (opType == OperationType.FLUSH)
+            return new RowMapping();
+        return DUMMY;
+    }
+
+    /**
+     * Link the term -> {@link PrimaryKeys} mappings from a provided {@link MemtableIndex} to
+     * the {@link PrimaryKey} -> row ID mappings maintained here in {@link #rowMapping} to produce
+     * mappings of terms to their postings lists.
+     *
+     * @param index a Memtable-attached column index
+     *
+     * @return an iterator of term -> postings list {@link Pair}s
+     */
+    public Iterator<Pair<ByteComparable, LongArrayList>> merge(MemtableIndex index)
+    {
+        assert complete : "RowMapping is not built.";
+
+        Iterator<Pair<ByteComparable, PrimaryKeys>> iterator = index.iterator();
+        return new AbstractGuavaIterator<Pair<ByteComparable, LongArrayList>>()
+        {
+            @Override
+            protected Pair<ByteComparable, LongArrayList> computeNext()
+            {
+                while (iterator.hasNext())
+                {
+                    Pair<ByteComparable, PrimaryKeys> pair = iterator.next();
+
+                    LongArrayList postings = null;
+                    Iterator<PrimaryKey> primaryKeys = pair.right.iterator();
+
+                    while (primaryKeys.hasNext())
+                    {
+                        PrimaryKey primaryKey = primaryKeys.next();
+                        ByteComparable byteComparable = primaryKey::asComparableBytes;
+                        Long sstableRowId = rowMapping.get(byteComparable);
+
+                        // The in-memory index does not handle deletions, so it is possible to
+                        // have a primary key in the index that doesn't exist in the row mapping
+                        if (sstableRowId != null)
+                        {
+                            postings = postings == null ? new LongArrayList() : postings;
+                            postings.add(sstableRowId);
+                        }
+                    }
+                    if (postings != null)
+                        return Pair.create(pair.left, postings);
+                }
+                return endOfData();
+            }
+        };
+    }
+
+    /**
+     * Complete building in memory RowMapping, mark it as immutable.
+     */
+    public void complete()
+    {
+        assert !complete : "RowMapping can only be built once.";
+        this.complete = true;
+    }
+
+    public boolean isComplete()
+    {
+        return complete;
+    }
+
+    /**
+     * Include PrimaryKey to RowId mapping
+     */
+    public void add(PrimaryKey key, long sstableRowId) throws InMemoryTrie.SpaceExhaustedException
+    {
+        assert !complete : "Cannot modify built RowMapping.";
+
+        ByteComparable byteComparable = key::asComparableBytes;
+        rowMapping.putSingleton(byteComparable, sstableRowId, OVERWRITE_TRANSFORMER);
+
+        maxSSTableRowId = Math.max(maxSSTableRowId, sstableRowId);
+
+        // data is written in token sorted order
+        if (minKey == null)
+            minKey = key;
+        maxKey = key;
+    }
+
+    public boolean hasRows()
+    {
+        return maxSSTableRowId >= 0;
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/SSTableIndex.java b/src/java/org/apache/cassandra/index/sai/disk/SSTableIndex.java
new file mode 100644
index 0000000000..2227291626
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/SSTableIndex.java
@@ -0,0 +1,260 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.index.sai.disk;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Objects;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.cassandra.db.PartitionPosition;
+import org.apache.cassandra.db.virtual.SimpleDataSet;
+import org.apache.cassandra.dht.AbstractBounds;
+import org.apache.cassandra.index.sai.IndexContext;
+import org.apache.cassandra.index.sai.QueryContext;
+import org.apache.cassandra.index.sai.SSTableContext;
+import org.apache.cassandra.index.sai.disk.format.Version;
+import org.apache.cassandra.index.sai.iterators.KeyRangeIterator;
+import org.apache.cassandra.index.sai.plan.Expression;
+import org.apache.cassandra.io.sstable.SSTableIdFactory;
+import org.apache.cassandra.io.sstable.format.SSTableReader;
+
+/**
+ * A reference-counted container of a {@link SSTableReader} for each column index that:
+ * <ul>
+ *     <li>Manages references to the SSTable for each query</li>
+ *     <li>Exposes a version agnostic searcher onto the column index</li>
+ *     <li>Exposes the index metadata for the column index</li>
+ * </ul>
+ */
+public abstract class SSTableIndex
+{
+    private static final Logger logger = LoggerFactory.getLogger(SSTableIndex.class);
+
+    // sort sstable indexes by first key, then last key, then descriptor id
+    public static final Comparator<SSTableIndex> COMPARATOR = Comparator.comparing((SSTableIndex s) -> s.getSSTable().first)
+                                                                        .thenComparing(s -> s.getSSTable().last)
+                                                                        .thenComparing(s -> s.getSSTable().descriptor.id, SSTableIdFactory.COMPARATOR);
+
+    protected final SSTableContext sstableContext;
+    protected final IndexContext indexContext;
+
+    private final AtomicInteger references = new AtomicInteger(1);
+    private final AtomicBoolean obsolete = new AtomicBoolean(false);
+
+    public SSTableIndex(SSTableContext sstableContext, IndexContext indexContext)
+    {
+        assert indexContext.getValidator() != null;
+
+        this.sstableContext = sstableContext.sharedCopy(); // this line must not be before any code that may throw
+        this.indexContext = indexContext;
+    }
+
+    /**
+     * Returns the amount of memory occupied by the index when it is initially loaded.
+     * This is the amount of data loaded into internal memory buffers by the index and
+     * does include the class footprint overhead. It used by the index metrics.
+     */
+    public abstract long indexFileCacheSize();
+
+    /**
+     * Returns the number of indexed rows in the index. This comes from the index
+     * metadata created when the index was written and is used by the index metrics.
+     */
+    public abstract long getRowCount();
+
+    /**
+     * Returns the minimum indexed rowId for the index. This comes from the index
+     * metadata created when the index was written and is used by the index metrics.
+     */
+    public abstract long minSSTableRowId();
+
+    /**
+     * Returns the maximum indexed rowId for the index. This comes from the index
+     * metadata created when the index was written and is used by the index metrics.
+     */
+    public abstract long maxSSTableRowId();
+
+    /**
+     * Returns the minimum term held in the index based on the natural sort order of
+     * the index column type comparator. It comes from the index metadata created when
+     * the index was written and is used by the index metrics and used in queries to
+     * determine whether a term, or range or terms, exists in the index.
+     */
+    public abstract ByteBuffer minTerm();
+
+    /**
+     * Returns the maximum term held in the index based on the natural sort order of
+     * the index column type comparator. It comes from the index metadata created when
+     * the index was written and is used by the index metrics and used in queries to
+     * determine whether a term, or range or terms, exists in the index.
+     */
+    public abstract ByteBuffer maxTerm();
+
+    /**
+     * Returns the key bounds of the index. It is created from the minimum and
+     * maximum keys held in the metadata and is used to determine whether
+     * sstable indexes overlap or not.
+     */
+    public abstract AbstractBounds<PartitionPosition> bounds();
+
+    /**
+     * Perform a search on the index for a single expression and keyRange.
+     *
+     * The result is a {@link List} of {@link KeyRangeIterator} because there will
+     * be a {@link KeyRangeIterator} for each segment in the index. The result
+     * will never be null but may be an empty {@link List}.
+     *
+     * @param expression The {@link Expression} to be searched for
+     * @param keyRange The {@link AbstractBounds<PartitionPosition>} defining the
+     *                 token range for the search
+     * @param context The {@link QueryContext} holding the per-query state
+     * @return a {@link List} of {@link KeyRangeIterator}s containing the results
+     * of the search
+     */
+    public abstract List<KeyRangeIterator> search(Expression expression,
+                                                  AbstractBounds<PartitionPosition> keyRange,
+                                                  QueryContext context) throws IOException;
+
+    /**
+     * Populates a virtual table using the index metadata owned by the index
+     */
+    public abstract void populateSegmentView(SimpleDataSet dataSet);
+
+    protected abstract void internalRelease();
+
+    /**
+     * @return total size of per-column index components, in bytes
+     */
+    public long sizeOfPerColumnComponents()
+    {
+        return sstableContext.indexDescriptor.sizeOnDiskOfPerIndexComponents(indexContext);
+    }
+
+    public IndexContext getIndexContext()
+    {
+        return indexContext;
+    }
+
+    public SSTableContext getSSTableContext()
+    {
+        return sstableContext;
+    }
+
+    public Version getVersion()
+    {
+        return sstableContext.indexDescriptor.version;
+    }
+
+    public SSTableReader getSSTable()
+    {
+        return sstableContext.sstable;
+    }
+
+    public boolean reference()
+    {
+        while (true)
+        {
+            int n = references.get();
+            if (n <= 0)
+                return false;
+            if (references.compareAndSet(n, n + 1))
+            {
+                return true;
+            }
+        }
+    }
+
+    public boolean isReleased()
+    {
+        return references.get() <= 0;
+    }
+
+    public void releaseQuietly()
+    {
+        try
+        {
+            release();
+        }
+        catch (Throwable e)
+        {
+            logger.error(getIndexContext().logMessage("Failed to release index on SSTable {}"), getSSTable().descriptor, e);
+        }
+    }
+
+    public void release()
+    {
+        int n = references.decrementAndGet();
+
+        if (n == 0)
+        {
+            internalRelease();
+            sstableContext.close();
+
+            /*
+             * When SSTable is removed, storage-attached index components will be automatically removed by LogTransaction.
+             * We only remove index components explicitly in case of index corruption or index rebuild.
+             */
+            if (obsolete.get())
+            {
+                sstableContext.indexDescriptor.deleteColumnIndex(indexContext);
+            }
+        }
+    }
+
+    public void markObsolete()
+    {
+        obsolete.getAndSet(true);
+        release();
+    }
+
+    @Override
+    public boolean equals(Object o)
+    {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        SSTableIndex other = (SSTableIndex)o;
+        return Objects.equal(sstableContext, other.sstableContext) && Objects.equal(indexContext, other.indexContext);
+    }
+
+    @Override
+    public int hashCode()
+    {
+        return Objects.hashCode(sstableContext, indexContext);
+    }
+
+    @Override
+    public String toString()
+    {
+        return MoreObjects.toStringHelper(this)
+                          .add("column", indexContext.getColumnName())
+                          .add("sstable", sstableContext.sstable.descriptor)
+                          .add("minTerm", indexContext.getValidator().getString(minTerm()))
+                          .add("maxTerm", indexContext.getValidator().getString(maxTerm()))
+                          .add("totalRows", sstableContext.sstable.getTotalRows())
+                          .toString();
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/StorageAttachedIndexWriter.java b/src/java/org/apache/cassandra/index/sai/disk/StorageAttachedIndexWriter.java
new file mode 100644
index 0000000000..f315e0dbba
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/StorageAttachedIndexWriter.java
@@ -0,0 +1,258 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Objects;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import javax.annotation.concurrent.NotThreadSafe;
+
+import com.google.common.base.Stopwatch;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.cassandra.db.DecoratedKey;
+import org.apache.cassandra.db.lifecycle.LifecycleNewTracker;
+import org.apache.cassandra.db.rows.Row;
+import org.apache.cassandra.db.rows.Unfiltered;
+import org.apache.cassandra.db.tries.InMemoryTrie;
+import org.apache.cassandra.index.sai.StorageAttachedIndex;
+import org.apache.cassandra.index.sai.disk.format.IndexDescriptor;
+import org.apache.cassandra.index.sai.utils.PrimaryKey;
+import org.apache.cassandra.io.sstable.SSTableFlushObserver;
+import org.apache.cassandra.utils.Throwables;
+
+/**
+ * Writes all on-disk index structures attached to a given SSTable.
+ */
+@NotThreadSafe
+public class StorageAttachedIndexWriter implements SSTableFlushObserver
+{
+    private static final Logger logger = LoggerFactory.getLogger(StorageAttachedIndexWriter.class);
+
+    private final IndexDescriptor indexDescriptor;
+    private final Collection<StorageAttachedIndex> indexes;
+    private final Collection<PerColumnIndexWriter> perIndexWriters;
+    private final PerSSTableIndexWriter perSSTableWriter;
+    private final Stopwatch stopwatch = Stopwatch.createUnstarted();
+    private final RowMapping rowMapping;
+    private final boolean propagateErrors;
+
+    private DecoratedKey currentKey;
+    private boolean tokenOffsetWriterCompleted = false;
+    private boolean aborted = false;
+
+    private long sstableRowId = 0;
+
+    public static StorageAttachedIndexWriter createFlushObserverWriter(IndexDescriptor indexDescriptor,
+                                                                       Collection<StorageAttachedIndex> indexes,
+                                                                       LifecycleNewTracker lifecycleNewTracker) throws IOException
+    {
+        return new StorageAttachedIndexWriter(indexDescriptor, indexes, lifecycleNewTracker, false, false);
+
+    }
+
+    public static StorageAttachedIndexWriter createBuilderWriter(IndexDescriptor indexDescriptor,
+                                                                 Collection<StorageAttachedIndex> indexes,
+                                                                 LifecycleNewTracker lifecycleNewTracker,
+                                                                 boolean perIndexComponentsOnly) throws IOException
+    {
+        return new StorageAttachedIndexWriter(indexDescriptor, indexes, lifecycleNewTracker, perIndexComponentsOnly, true);
+    }
+
+    private StorageAttachedIndexWriter(IndexDescriptor indexDescriptor,
+                                       Collection<StorageAttachedIndex> indexes,
+                                       LifecycleNewTracker lifecycleNewTracker,
+                                       boolean perIndexComponentsOnly,
+                                       boolean propagateErrors) throws IOException
+    {
+        this.indexDescriptor = indexDescriptor;
+        this.indexes = indexes;
+        this.rowMapping = RowMapping.create(lifecycleNewTracker.opType());
+        this.propagateErrors = propagateErrors;
+        this.perIndexWriters = indexes.stream().map(index -> indexDescriptor.newPerColumnIndexWriter(index,
+                                                                                                     lifecycleNewTracker,
+                                                                                                     rowMapping))
+                                      .filter(Objects::nonNull) // a null here means the column had no data to flush
+                                      .collect(Collectors.toList());
+
+        // If the SSTable components are already being built by another index build then we don't want
+        // to build them again so use a null writer
+        this.perSSTableWriter = perIndexComponentsOnly ? PerSSTableIndexWriter.NONE : indexDescriptor.newPerSSTableIndexWriter();
+    }
+
+    @Override
+    public void begin()
+    {
+        logger.debug(indexDescriptor.logMessage("Starting partition iteration for storage-attached index flush for SSTable {}..."), indexDescriptor.sstableDescriptor);
+        stopwatch.start();
+    }
+
+    @Override
+    public void startPartition(DecoratedKey key, long keyPosition, long keyPositionForSASI)
+    {
+        if (aborted) return;
+        
+        currentKey = key;
+    }
+
+    @Override
+    public void nextUnfilteredCluster(Unfiltered unfiltered)
+    {
+        if (aborted) return;
+
+        // Ignore range tombstones...
+        if (!unfiltered.isRow())
+            return;
+
+        try
+        {
+            addRow((Row)unfiltered);
+        }
+        catch (Throwable t)
+        {
+            logger.error(indexDescriptor.logMessage("Failed to record a row during an index build"), t);
+            abort(t, true);
+        }
+    }
+
+    @Override
+    public void staticRow(Row staticRow)
+    {
+        if (aborted) return;
+        
+        if (staticRow.isEmpty())
+            return;
+
+        try
+        {
+            addRow(staticRow);
+        }
+        catch (Throwable t)
+        {
+            logger.error(indexDescriptor.logMessage("Failed to record a static row during an index build"), t);
+            abort(t, true);
+        }
+    }
+
+    @Override
+    public void complete()
+    {
+        if (aborted) return;
+
+        long start = stopwatch.elapsed(TimeUnit.MILLISECONDS);
+
+        logger.debug(indexDescriptor.logMessage("Completed partition iteration for index flush for SSTable {}. Elapsed time: {} ms"),
+                     indexDescriptor.sstableDescriptor, start);
+
+        try
+        {
+            perSSTableWriter.complete();
+            tokenOffsetWriterCompleted = true;
+
+            long elapsed = stopwatch.elapsed(TimeUnit.MILLISECONDS);
+
+            logger.debug(indexDescriptor.logMessage("Completed per-SSTable write for SSTable {}. Duration: {} ms. Total elapsed time: {} ms."),
+                         indexDescriptor.sstableDescriptor, elapsed - start, elapsed);
+
+            start = elapsed;
+
+            rowMapping.complete();
+
+            for (PerColumnIndexWriter perIndexWriter : perIndexWriters)
+            {
+                perIndexWriter.complete(stopwatch);
+            }
+            elapsed = stopwatch.elapsed(TimeUnit.MILLISECONDS);
+            logger.debug(indexDescriptor.logMessage("Completed per-index writes for SSTable {}. Duration: {} ms. Total elapsed time: {} ms."),
+                         indexDescriptor.sstableDescriptor, elapsed - start, elapsed);
+        }
+        catch (Throwable t)
+        {
+            logger.error(indexDescriptor.logMessage("Failed to complete an index build"), t);
+            abort(t, true);
+        }
+    }
+
+    /**
+     * Aborts all column index writers and, only if they have not yet completed, SSTable-level component writers.
+     * 
+     * @param accumulator the initial exception thrown from the failed writer
+     */
+    @Override
+    public void abort(Throwable accumulator)
+    {
+        abort(accumulator, false);
+    }
+
+    /**
+     *
+     * @param accumulator original cause of the abort
+     * @param fromIndex true if the cause of the abort was the index itself, false otherwise
+     */
+    public void abort(Throwable accumulator, boolean fromIndex)
+    {
+        // Mark the write operation aborted, so we can short-circuit any further operations on the component writers.
+        aborted = true;
+        
+        // Make any indexes involved in this transaction non-queryable, as they will likely not match the backing table.
+        if (fromIndex)
+            indexes.forEach(StorageAttachedIndex::makeIndexNonQueryable);
+        
+        for (PerColumnIndexWriter perIndexWriter : perIndexWriters)
+        {
+            try
+            {
+                perIndexWriter.abort(accumulator);
+            }
+            catch (Throwable t)
+            {
+                if (accumulator != null)
+                {
+                    accumulator.addSuppressed(t);
+                }
+            }
+        }
+        
+        if (!tokenOffsetWriterCompleted)
+        {
+            // If the token/offset files have already been written successfully, they can be reused later. 
+            perSSTableWriter.abort();
+        }
+
+        // If the abort was from an index error and this is part of an index build operation then
+        // propagate the error up to the SecondaryIndexManager, so it can be correctly marked as failed.
+        if (fromIndex && propagateErrors)
+            throw Throwables.unchecked(accumulator);
+    }
+
+    private void addRow(Row row) throws IOException, InMemoryTrie.SpaceExhaustedException
+    {
+        PrimaryKey primaryKey = indexDescriptor.primaryKeyFactory.create(currentKey, row.clustering());
+        perSSTableWriter.nextRow(primaryKey);
+        rowMapping.add(primaryKey, sstableRowId);
+
+        for (PerColumnIndexWriter w : perIndexWriters)
+        {
+            w.addRow(primaryKey, row, sstableRowId);
+        }
+        sstableRowId++;
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/format/IndexComponent.java b/src/java/org/apache/cassandra/index/sai/disk/format/IndexComponent.java
new file mode 100644
index 0000000000..670ba82c09
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/format/IndexComponent.java
@@ -0,0 +1,88 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.index.sai.disk.format;
+
+import org.apache.cassandra.index.sai.disk.v1.postings.PostingsWriter;
+import org.apache.cassandra.index.sai.disk.v1.trie.TrieTermsDictionaryWriter;
+
+/**
+ * This is a definitive list of all the on-disk components for all versions
+ */
+public enum IndexComponent
+{
+    /**
+     * Metadata for per-column index components
+     */
+    META("Meta"),
+
+    /**
+     * Term dictionary written by {@link TrieTermsDictionaryWriter} stores mappings of term and
+     * file pointer to posting block on posting file.
+     */
+    TERMS_DATA("TermsData"),
+
+    /**
+     * Stores postings written by {@link PostingsWriter}
+     */
+    POSTING_LISTS("PostingLists"),
+
+    /**
+     * If present indicates that the column index build completed successfully
+     */
+    COLUMN_COMPLETION_MARKER("ColumnComplete"),
+
+
+    // per-sstable components
+    /**
+     * Partition key token value for rows including row tombstone and static row. (access key is rowId)
+     */
+    TOKEN_VALUES("TokenValues"),
+
+    /**
+     * An on-disk trie containing the primary keys used for looking up the rowId from a partition key
+     */
+    PRIMARY_KEY_TRIE("PrimaryKeyTrie"),
+
+    /**
+     * Prefix-compressed blocks of primary keys used for rowId to partition key lookups
+     */
+    PRIMARY_KEY_BLOCKS("PrimaryKeyBlocks"),
+
+    /**
+     * Encoded sequence of offsets to primary key blocks
+     */
+    PRIMARY_KEY_BLOCK_OFFSETS("PrimaryKeyBlockOffsets"),
+
+    /**
+     * Metadata for per-SSTable on-disk components.
+     */
+    GROUP_META("GroupMeta"),
+
+    /**
+     * If present indicates that the per-sstable index build completed successfully
+     */
+    GROUP_COMPLETION_MARKER("GroupComplete");
+
+    public final String name;
+
+    IndexComponent(String name)
+    {
+        this.name = name;
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/format/IndexDescriptor.java b/src/java/org/apache/cassandra/index/sai/disk/format/IndexDescriptor.java
new file mode 100644
index 0000000000..7261671eca
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/format/IndexDescriptor.java
@@ -0,0 +1,433 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.index.sai.disk.format;
+
+import java.io.IOException;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Objects;
+import com.google.common.io.Files;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.cassandra.db.ClusteringComparator;
+import org.apache.cassandra.db.lifecycle.LifecycleNewTracker;
+import org.apache.cassandra.index.sai.IndexContext;
+import org.apache.cassandra.index.sai.SSTableContext;
+import org.apache.cassandra.index.sai.StorageAttachedIndex;
+import org.apache.cassandra.index.sai.disk.PerColumnIndexWriter;
+import org.apache.cassandra.index.sai.disk.PerSSTableIndexWriter;
+import org.apache.cassandra.index.sai.disk.PrimaryKeyMap;
+import org.apache.cassandra.index.sai.disk.RowMapping;
+import org.apache.cassandra.index.sai.disk.SSTableIndex;
+import org.apache.cassandra.index.sai.disk.io.IndexFileUtils;
+import org.apache.cassandra.index.sai.disk.io.IndexOutputWriter;
+import org.apache.cassandra.index.sai.utils.PrimaryKey;
+import org.apache.cassandra.io.sstable.Component;
+import org.apache.cassandra.io.sstable.Descriptor;
+import org.apache.cassandra.io.sstable.format.SSTableReader;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileHandle;
+import org.apache.cassandra.utils.FBUtilities;
+import org.apache.lucene.store.IndexInput;
+import org.apache.lucene.util.IOUtils;
+
+/**
+ * The {@link IndexDescriptor} is an analog of the SSTable {@link Descriptor} and provides version
+ * specific information about the on-disk state of a {@link StorageAttachedIndex}.
+ *
+ * The {@link IndexDescriptor} is primarily responsible for maintaining a view of the on-disk state
+ * of an index for a specific {@link org.apache.cassandra.io.sstable.SSTable}.
+ *
+ * It is responsible for opening files for use by writers and readers.
+ *
+ * Its remaining responsibility is to act as a proxy to the {@link OnDiskFormat} associated with the
+ * index {@link Version}.
+ */
+public class IndexDescriptor
+{
+    private static final Logger logger = LoggerFactory.getLogger(IndexDescriptor.class);
+
+    public final Version version;
+    public final Descriptor sstableDescriptor;
+    public final ClusteringComparator clusteringComparator;
+    public final PrimaryKey.Factory primaryKeyFactory;
+
+    private IndexDescriptor(Version version, Descriptor sstableDescriptor, ClusteringComparator clusteringComparator)
+    {
+        this.version = version;
+        this.sstableDescriptor = sstableDescriptor;
+        this.clusteringComparator = clusteringComparator;
+        this.primaryKeyFactory = new PrimaryKey.Factory(clusteringComparator);
+    }
+
+    public static IndexDescriptor create(Descriptor descriptor, ClusteringComparator clusteringComparator)
+    {
+        return new IndexDescriptor(Version.LATEST, descriptor, clusteringComparator);
+    }
+
+    public static IndexDescriptor create(SSTableReader sstable)
+    {
+        for (Version version : Version.ALL)
+        {
+            IndexDescriptor indexDescriptor = new IndexDescriptor(version,
+                                                                  sstable.descriptor,
+                                                                  sstable.metadata().comparator);
+
+            if (version.onDiskFormat().isPerSSTableIndexBuildComplete(indexDescriptor))
+            {
+                return indexDescriptor;
+            }
+        }
+        return new IndexDescriptor(Version.LATEST,
+                                   sstable.descriptor,
+                                   sstable.metadata().comparator);
+    }
+
+    public String componentName(IndexComponent indexComponent)
+    {
+        return version.fileNameFormatter().format(indexComponent, null);
+    }
+
+    public PrimaryKeyMap.Factory newPrimaryKeyMapFactory(SSTableReader sstable)
+    {
+        return version.onDiskFormat().newPrimaryKeyMapFactory(this, sstable);
+    }
+
+    public SSTableIndex newSSTableIndex(SSTableContext sstableContext, IndexContext indexContext)
+    {
+        return version.onDiskFormat().newSSTableIndex(sstableContext, indexContext);
+    }
+
+    public PerSSTableIndexWriter newPerSSTableIndexWriter() throws IOException
+    {
+        return version.onDiskFormat().newPerSSTableIndexWriter(this);
+    }
+
+    public PerColumnIndexWriter newPerColumnIndexWriter(StorageAttachedIndex index,
+                                                        LifecycleNewTracker tracker,
+                                                        RowMapping rowMapping)
+    {
+        return version.onDiskFormat().newPerColumnIndexWriter(index, this, tracker, rowMapping);
+    }
+
+    public boolean isPerSSTableIndexBuildComplete()
+    {
+        return version.onDiskFormat().isPerSSTableIndexBuildComplete(this);
+    }
+
+    public boolean isPerColumnIndexBuildComplete(IndexContext indexContext)
+    {
+        return version.onDiskFormat().isPerColumnIndexBuildComplete(this, indexContext);
+    }
+
+    public boolean hasComponent(IndexComponent indexComponent)
+    {
+        return fileFor(indexComponent).exists();
+    }
+
+    public boolean hasComponent(IndexComponent indexComponent, IndexContext indexContext)
+    {
+        return fileFor(indexComponent, indexContext).exists();
+    }
+
+    public File fileFor(IndexComponent indexComponent)
+    {
+        return createFile(indexComponent, null);
+    }
+
+    public File fileFor(IndexComponent indexComponent, IndexContext indexContext)
+    {
+        return createFile(indexComponent, indexContext);
+    }
+
+    public boolean isIndexEmpty(IndexContext indexContext)
+    {
+        // The index is empty if the index build completed successfully in that both
+        // a GROUP_COMPLETION_MARKER companent and a COLUMN_COMPLETION_MARKER exist for
+        // the index and the number of per-index components is 1 indicating that only the
+        // COLUMN_COMPLETION_MARKER exists for the index, as this is the only file that
+        // will be written if the index is empty
+        return isPerColumnIndexBuildComplete(indexContext) && numberOfPerIndexComponents(indexContext) == 1;
+    }
+
+    @SuppressWarnings("UnstableApiUsage")
+    public void createComponentOnDisk(IndexComponent component) throws IOException
+    {
+        Files.touch(fileFor(component).toJavaIOFile());
+    }
+
+    @SuppressWarnings("UnstableApiUsage")
+    public void createComponentOnDisk(IndexComponent component, IndexContext indexContext) throws IOException
+    {
+        Files.touch(fileFor(component, indexContext).toJavaIOFile());
+    }
+
+    public IndexInput openPerSSTableInput(IndexComponent indexComponent)
+    {
+        File file = fileFor(indexComponent);
+        if (logger.isTraceEnabled())
+            logger.trace(logMessage("Opening blocking index input for file {} ({})"),
+                         file,
+                         FBUtilities.prettyPrintMemory(file.length()));
+
+        return IndexFileUtils.instance.openBlockingInput(file);
+    }
+
+    public IndexInput openPerIndexInput(IndexComponent indexComponent, IndexContext indexContext)
+    {
+        final File file = fileFor(indexComponent, indexContext);
+        if (logger.isTraceEnabled())
+            logger.trace(logMessage("Opening blocking index input for file {} ({})"),
+                         file,
+                         FBUtilities.prettyPrintMemory(file.length()));
+
+        return IndexFileUtils.instance.openBlockingInput(file);
+    }
+
+    public IndexOutputWriter openPerSSTableOutput(IndexComponent component) throws IOException
+    {
+        return openPerSSTableOutput(component, false);
+    }
+
+    public IndexOutputWriter openPerSSTableOutput(IndexComponent component, boolean append) throws IOException
+    {
+        final File file = fileFor(component);
+
+        if (logger.isTraceEnabled())
+            logger.trace(logMessage("Creating SSTable attached index output for component {} on file {}..."),
+                         component,
+                         file);
+
+        IndexOutputWriter writer = IndexFileUtils.instance.openOutput(file);
+
+        if (append)
+        {
+            writer.skipBytes(file.length());
+        }
+
+        return writer;
+    }
+
+    public IndexOutputWriter openPerIndexOutput(IndexComponent indexComponent, IndexContext indexContext) throws IOException
+    {
+        return openPerIndexOutput(indexComponent, indexContext, false);
+    }
+
+    public IndexOutputWriter openPerIndexOutput(IndexComponent component, IndexContext indexContext, boolean append) throws IOException
+    {
+        final File file = fileFor(component, indexContext);
+
+        if (logger.isTraceEnabled())
+            logger.trace(indexContext.logMessage("Creating sstable attached index output for component {} on file {}..."),
+                         component,
+                         file);
+
+        IndexOutputWriter writer = IndexFileUtils.instance.openOutput(file);
+
+        if (append)
+        {
+            writer.skipBytes(file.length());
+        }
+
+        return writer;
+    }
+
+    public FileHandle createPerSSTableFileHandle(IndexComponent indexComponent)
+    {
+        final File file = fileFor(indexComponent);
+
+        if (logger.isTraceEnabled())
+        {
+            logger.trace(logMessage("Opening {} file handle for {} ({})"),
+                         file, FBUtilities.prettyPrintMemory(file.length()));
+        }
+
+        return new FileHandle.Builder(file).mmapped(true).complete();
+    }
+
+    public FileHandle createPerIndexFileHandle(IndexComponent indexComponent, IndexContext indexContext)
+    {
+        final File file = fileFor(indexComponent, indexContext);
+
+        if (logger.isTraceEnabled())
+        {
+            logger.trace(indexContext.logMessage("Opening file handle for {} ({})"),
+                         file, FBUtilities.prettyPrintMemory(file.length()));
+        }
+
+        return new FileHandle.Builder(file).mmapped(true).complete();
+    }
+
+    public Set<Component> getLivePerSSTableComponents()
+    {
+        return version.onDiskFormat()
+                      .perSSTableIndexComponents()
+                      .stream()
+                      .filter(c -> fileFor(c).exists())
+                      .map(version::makePerSSTableComponent)
+                      .collect(Collectors.toSet());
+    }
+
+    public Set<Component> getLivePerIndexComponents(IndexContext indexContext)
+    {
+        return version.onDiskFormat()
+                      .perColumnIndexComponents(indexContext)
+                      .stream()
+                      .filter(c -> fileFor(c, indexContext).exists())
+                      .map(c -> version.makePerIndexComponent(c, indexContext))
+                      .collect(Collectors.toSet());
+    }
+
+    public long sizeOnDiskOfPerSSTableComponents()
+    {
+        return version.onDiskFormat()
+                      .perSSTableIndexComponents()
+                      .stream()
+                      .map(this::fileFor)
+                      .filter(File::exists)
+                      .mapToLong(File::length)
+                      .sum();
+    }
+
+    public long sizeOnDiskOfPerIndexComponents(IndexContext indexContext)
+    {
+        return version.onDiskFormat()
+                      .perColumnIndexComponents(indexContext)
+                      .stream()
+                      .map(c -> fileFor(c, indexContext))
+                      .filter(File::exists)
+                      .mapToLong(File::length)
+                      .sum();
+    }
+
+    @VisibleForTesting
+    public long sizeOnDiskOfPerIndexComponent(IndexComponent indexComponent, IndexContext indexContext)
+    {
+        File componentFile = fileFor(indexComponent, indexContext);
+        return componentFile.exists() ? componentFile.length() : 0;
+    }
+
+    @SuppressWarnings("BooleanMethodIsAlwaysInverted")
+    public boolean validatePerIndexComponents(IndexContext indexContext)
+    {
+        logger.info(indexContext.logMessage("Validating per-column index components"));
+        return version.onDiskFormat().validatePerColumnIndexComponents(this, indexContext, false);
+    }
+
+    @VisibleForTesting
+    public boolean validatePerIndexComponentsChecksum(IndexContext indexContext)
+    {
+        return version.onDiskFormat().validatePerColumnIndexComponents(this, indexContext, true);
+    }
+
+    @SuppressWarnings("BooleanMethodIsAlwaysInverted")
+    public boolean validatePerSSTableComponents()
+    {
+        return version.onDiskFormat().validatePerSSTableIndexComponents(this, false);
+    }
+
+    @SuppressWarnings("BooleanMethodIsAlwaysInverted")
+    public boolean validatePerSSTableComponentsChecksum()
+    {
+        return version.onDiskFormat().validatePerSSTableIndexComponents(this, true);
+    }
+
+    public void deletePerSSTableIndexComponents()
+    {
+        version.onDiskFormat()
+               .perSSTableIndexComponents()
+               .stream()
+               .map(this::fileFor)
+               .filter(File::exists)
+               .forEach(this::deleteComponent);
+    }
+
+    public void deleteColumnIndex(IndexContext indexContext)
+    {
+        version.onDiskFormat()
+               .perColumnIndexComponents(indexContext)
+               .stream()
+               .map(c -> fileFor(c, indexContext))
+               .filter(File::exists)
+               .forEach(this::deleteComponent);
+    }
+
+    @Override
+    public int hashCode()
+    {
+        return Objects.hashCode(sstableDescriptor, version);
+    }
+
+    @Override
+    public boolean equals(Object o)
+    {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        IndexDescriptor other = (IndexDescriptor)o;
+        return Objects.equal(sstableDescriptor, other.sstableDescriptor) &&
+               Objects.equal(version, other.version);
+    }
+
+    @Override
+    public String toString()
+    {
+        return sstableDescriptor.toString() + "-SAI";
+    }
+
+    public String logMessage(String message)
+    {
+        // Index names are unique only within a keyspace.
+        return String.format("[%s.%s.*] %s",
+                             sstableDescriptor.ksname,
+                             sstableDescriptor.cfname,
+                             message);
+    }
+
+    private File createFile(IndexComponent component, IndexContext indexContext)
+    {
+        Component customComponent = version.makePerIndexComponent(component, indexContext);
+        return sstableDescriptor.fileFor(customComponent);
+    }
+
+    private long numberOfPerIndexComponents(IndexContext indexContext)
+    {
+        return version.onDiskFormat()
+                      .perColumnIndexComponents(indexContext)
+                      .stream()
+                      .map(c -> fileFor(c, indexContext))
+                      .filter(File::exists)
+                      .count();
+    }
+
+    private void deleteComponent(File file)
+    {
+        logger.debug(logMessage("Deleting storage-attached index component file {}"), file);
+        try
+        {
+            IOUtils.deleteFilesIfExist(file.toPath());
+        }
+        catch (IOException e)
+        {
+            logger.warn(logMessage("Unable to delete storage-attached index component file {} due to {}."), file, e.getMessage(), e);
+        }
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/format/OnDiskFormat.java b/src/java/org/apache/cassandra/index/sai/disk/format/OnDiskFormat.java
new file mode 100644
index 0000000000..cf19dceb6b
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/format/OnDiskFormat.java
@@ -0,0 +1,165 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.index.sai.disk.format;
+
+import java.io.IOException;
+import java.util.Set;
+
+import org.apache.cassandra.db.lifecycle.LifecycleNewTracker;
+import org.apache.cassandra.index.sai.IndexContext;
+import org.apache.cassandra.index.sai.SSTableContext;
+import org.apache.cassandra.index.sai.StorageAttachedIndex;
+import org.apache.cassandra.index.sai.disk.PerColumnIndexWriter;
+import org.apache.cassandra.index.sai.disk.PerSSTableIndexWriter;
+import org.apache.cassandra.index.sai.disk.PrimaryKeyMap;
+import org.apache.cassandra.index.sai.disk.RowMapping;
+import org.apache.cassandra.index.sai.disk.SSTableIndex;
+import org.apache.cassandra.io.sstable.format.SSTableReader;
+
+/**
+ * An interface to the on-disk format of an index. This provides format agnostic methods
+ * to read and write an on-disk format.
+ *
+ * The methods on this interface can be logically mapped into the following groups
+ * based on their method parameters:
+ * <ul>
+ *     <li>Methods taking no parameters. These methods return static information about the
+ *     format. This can include static information about the per-sstable components</li>
+ *     <li>Methods taking just an {@link IndexContext}. These methods return static information
+ *     specific to the index. This can be information relating to the type of index being used</li>
+ *     <li>Methods taking an {@link IndexDescriptor}. These methods interact with the on-disk components, or
+ *     return objects that will interact with the on-disk components, or return information about the on-disk
+ *     components. If they take an {@link IndexContext} as well they will be interacting with per-column index files;
+ *     otherwise they will be interacting with per-sstable index files</li>
+ *     <li>Methods taking an {@link IndexComponent}. These methods only interact with a single index component or
+ *     set of index components</li>
+ *
+ * </ul>
+ */
+public interface OnDiskFormat
+{
+    /**
+     * Returns a {@link PrimaryKeyMap.Factory} for the SSTable
+     *
+     * @param indexDescriptor The {@link IndexDescriptor} for the SSTable
+     * @param sstable The {@link SSTableReader} associated with the {@link IndexDescriptor}
+     */
+    PrimaryKeyMap.Factory newPrimaryKeyMapFactory(IndexDescriptor indexDescriptor, SSTableReader sstable);
+
+    /**
+     * Create a new {@link SSTableIndex} for an on-disk index.
+     *
+     * @param sstableContext The {@link SSTableContext} holding the per-SSTable information for the index
+     * @param indexContext The {@link IndexContext} holding the per-index information for the index
+     * @return the new {@link SSTableIndex} for the on-disk index
+     */
+    SSTableIndex newSSTableIndex(SSTableContext sstableContext, IndexContext indexContext);
+
+    /**
+     * Create a new {@link PerSSTableIndexWriter} to write the per-SSTable on-disk components of an index.
+     *
+     * @param indexDescriptor The {@link IndexDescriptor} for the SSTable
+     * @throws IOException if the writer couldn't be created
+     */
+    PerSSTableIndexWriter newPerSSTableIndexWriter(IndexDescriptor indexDescriptor) throws IOException;
+
+    /**
+     * Create a new {@link PerColumnIndexWriter} to write the per-column on-disk components of an index. The {@link LifecycleNewTracker}
+     * is used to determine the type of index write about to happen this will either be an
+     * {@code OperationType.FLUSH} indicating that we are about to flush a {@link org.apache.cassandra.index.sai.memory.MemtableIndex}
+     * or one of the other operation types indicating that we will be writing from an existing SSTable
+     *
+     * @param index The {@link StorageAttachedIndex} holding the current index build status
+     * @param indexDescriptor The {@link IndexDescriptor} for the SSTable
+     * @param tracker The {@link LifecycleNewTracker} for index build operation.
+     * @param rowMapping The {@link RowMapping} that is used to map rowID to {@code PrimaryKey} during the write operation
+     */
+    PerColumnIndexWriter newPerColumnIndexWriter(StorageAttachedIndex index,
+                                                 IndexDescriptor indexDescriptor,
+                                                 LifecycleNewTracker tracker,
+                                                 RowMapping rowMapping);
+
+    /**
+     * Returns true if the per-sstable index components have been built and are valid.
+     *
+     * @param indexDescriptor The {@link IndexDescriptor} for the SSTable SAI index
+     */
+    boolean isPerSSTableIndexBuildComplete(IndexDescriptor indexDescriptor);
+
+    /**
+     * Returns true if the per-column index components have been built and are valid.
+     *
+     * @param indexDescriptor The {@link IndexDescriptor} for the SSTable SAI index
+     * @param indexContext The {@link IndexContext} for the index
+     */
+    boolean isPerColumnIndexBuildComplete(IndexDescriptor indexDescriptor, IndexContext indexContext);
+
+    /**
+     * Validate all the per-SSTable on-disk components and throw if a component is not valid
+     *
+     * @param indexDescriptor The {@link IndexDescriptor} for the SSTable SAI index
+     * @param checksum {@code true} if the checksum should be tested as part of the validation
+     *
+     * @return true if all the per-SSTable components are valid
+     */
+    boolean validatePerSSTableIndexComponents(IndexDescriptor indexDescriptor, boolean checksum);
+
+    /**
+     * Validate all the per-column on-disk components and throw if a component is not valid
+     *
+     * @param indexDescriptor The {@link IndexDescriptor} for the SSTable SAI index
+     * @param indexContext The {@link IndexContext} holding the per-index information for the index
+     * @param checksum {@code true} if the checksum should be tested as part of the validation
+     *
+     * @return true if all the per-column components are valid
+     */
+    boolean validatePerColumnIndexComponents(IndexDescriptor indexDescriptor, IndexContext indexContext, boolean checksum);
+
+    /**
+     * Returns the set of {@link IndexComponent} for the per-SSTable part of an index.
+     * This is a complete set of components that could exist on-disk. It does not imply that the
+     * components currently exist on-disk.
+     */
+    Set<IndexComponent> perSSTableIndexComponents();
+
+    /**
+     * Returns the set of {@link IndexComponent} for the per-column part of an index.
+     * This is a complete set of components that could exist on-disk. It does not imply that the
+     * components currently exist on-disk.
+     *
+     * @param indexContext The {@link IndexContext} for the index
+     */
+    Set<IndexComponent> perColumnIndexComponents(IndexContext indexContext);
+
+    /**
+     * Return the number of open per-SSTable files that can be open during a query.
+     * This is a static indication of the files that can be held open by an index
+     * for queries. It is not a dynamic calculation.
+     */
+    int openFilesPerSSTableIndex();
+
+    /**
+     * Return the number of open per-column index files that can be open during a query.
+     * This is a static indication of the files that can be help open by an index
+     * for queries. It is not a dynamic calculation.
+     *
+     * @param indexContext The {@link IndexContext} for the index
+     */
+    int openFilesPerColumnIndex(IndexContext indexContext);
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/format/Version.java b/src/java/org/apache/cassandra/index/sai/disk/format/Version.java
new file mode 100644
index 0000000000..16e84d94c7
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/format/Version.java
@@ -0,0 +1,154 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk.format;
+
+import java.util.Comparator;
+import java.util.SortedSet;
+import java.util.TreeSet;
+import java.util.stream.Collectors;
+
+import com.google.common.base.Objects;
+
+import org.apache.cassandra.config.CassandraRelevantProperties;
+import org.apache.cassandra.index.sai.IndexContext;
+import org.apache.cassandra.index.sai.disk.v1.V1OnDiskFormat;
+import org.apache.cassandra.io.sstable.Component;
+import org.apache.cassandra.io.sstable.Descriptor;
+import org.apache.cassandra.io.sstable.format.SSTableFormat;
+
+/**
+ * Format version of indexing component, denoted as [major][minor]. Same forward-compatibility rules apply as to
+ * {@link org.apache.cassandra.io.sstable.format.Version}.
+ */
+public class Version implements Comparable<Version>
+{
+    private static final String SAI_DESCRIPTOR = "SAI";
+    private static final String SAI_SEPARATOR = "+";
+
+    // Current version
+    public static final Version AA = new Version("aa", V1OnDiskFormat.instance, (c, i) -> defaultFileNameFormat(c, i, "aa"));
+
+    // These should be added in reverse order so that the latest version is used first. Version matching tests
+    // are more likely to match the latest version, so we want to test that one first.
+    public static final SortedSet<Version> ALL = new TreeSet<Version>(Comparator.reverseOrder()) {{
+        add(AA);
+    }};
+
+    public static final Version EARLIEST = AA;
+    // The latest version can be configured to be an earlier version to support partial upgrades that don't
+    // write newer versions of the on-disk formats.
+    public static final Version LATEST = CassandraRelevantProperties.SAI_LATEST_VERSION.convert(Version::parse);
+
+    private final String version;
+    private final OnDiskFormat onDiskFormat;
+    private final FileNameFormatter fileNameFormatter;
+
+    private Version(String version, OnDiskFormat onDiskFormat, FileNameFormatter fileNameFormatter)
+    {
+        this.version = version;
+        this.onDiskFormat = onDiskFormat;
+        this.fileNameFormatter = fileNameFormatter;
+    }
+
+    public static Version parse(String versionString)
+    {
+        for (Version version : ALL)
+            if (version.version.equals(versionString))
+                return version;
+        throw new IllegalArgumentException("The version string " + versionString + " does not represent a valid SAI version. " +
+                                           "It should be one of " + ALL.stream().map(Version::toString).collect(Collectors.joining(", ")));
+    }
+
+    @Override
+    public int compareTo(Version other)
+    {
+        return version.compareTo(other.version);
+    }
+
+    @Override
+    public int hashCode()
+    {
+        return Objects.hashCode(version);
+    }
+
+    @Override
+    public boolean equals(Object o)
+    {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        Version other = (Version)o;
+        return Objects.equal(version, other.version);
+    }
+
+    @Override
+    public String toString()
+    {
+        return version;
+    }
+
+    public boolean onOrAfter(Version other)
+    {
+        return version.compareTo(other.version) >= 0;
+    }
+
+    public OnDiskFormat onDiskFormat()
+    {
+        return onDiskFormat;
+    }
+
+    public Component makePerSSTableComponent(IndexComponent indexComponent)
+    {
+        return SSTableFormat.Components.Types.CUSTOM.createComponent(fileNameFormatter.format(indexComponent, null));
+    }
+
+    public Component makePerIndexComponent(IndexComponent indexComponent, IndexContext indexContext)
+    {
+        return SSTableFormat.Components.Types.CUSTOM.createComponent(fileNameFormatter.format(indexComponent, indexContext));
+    }
+
+    public FileNameFormatter fileNameFormatter()
+    {
+        return fileNameFormatter;
+    }
+
+    public interface FileNameFormatter
+    {
+        String format(IndexComponent indexComponent, IndexContext indexContext);
+    }
+
+    /**
+     * SAI default filename formatter. This is the current SAI on-disk filename format
+     *
+     * Format: <sstable descriptor>-SAI+<version>(+<index name>)+<component name>.db
+     * Note: The index name is excluded for per-SSTable index files that are shared
+     * across all the per-column indexes for the SSTable.
+     */
+    private static String defaultFileNameFormat(IndexComponent indexComponent, IndexContext indexContext, String version)
+    {
+        StringBuilder stringBuilder = new StringBuilder();
+
+        stringBuilder.append(SAI_DESCRIPTOR);
+        stringBuilder.append(SAI_SEPARATOR).append(version);
+        if (indexContext != null)
+            stringBuilder.append(SAI_SEPARATOR).append(indexContext.getIndexName());
+        stringBuilder.append(SAI_SEPARATOR).append(indexComponent.name);
+        stringBuilder.append(Descriptor.EXTENSION);
+
+        return stringBuilder.toString();
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/io/IndexFileUtils.java b/src/java/org/apache/cassandra/index/sai/disk/io/IndexFileUtils.java
new file mode 100644
index 0000000000..1394d20555
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/io/IndexFileUtils.java
@@ -0,0 +1,148 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.index.sai.disk.io;
+
+import java.io.IOException;
+import java.nio.ByteOrder;
+import java.util.zip.CRC32;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.io.compress.BufferType;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.FileHandle;
+import org.apache.cassandra.io.util.RandomAccessReader;
+import org.apache.cassandra.io.util.SequentialWriter;
+import org.apache.cassandra.io.util.SequentialWriterOption;
+import org.apache.lucene.store.IndexInput;
+
+public class IndexFileUtils
+{
+    @VisibleForTesting
+    protected static final SequentialWriterOption DEFAULT_WRITER_OPTION = SequentialWriterOption.newBuilder()
+                                                                                                .trickleFsync(DatabaseDescriptor.getTrickleFsync())
+                                                                                                .trickleFsyncByteInterval(DatabaseDescriptor.getTrickleFsyncIntervalInKiB() * 1024)
+                                                                                                .bufferType(BufferType.OFF_HEAP)
+                                                                                                .finishOnClose(true)
+                                                                                                .build();
+
+    public static final IndexFileUtils instance = new IndexFileUtils(DEFAULT_WRITER_OPTION);
+
+    private final SequentialWriterOption writerOption;
+
+    @VisibleForTesting
+    protected IndexFileUtils(SequentialWriterOption writerOption)
+    {
+        this.writerOption = writerOption;
+    }
+
+    @SuppressWarnings({"resource", "RedundantSuppression"})
+    public IndexOutputWriter openOutput(File file)
+    {
+        assert writerOption.finishOnClose() : "IndexOutputWriter relies on close() to sync with disk.";
+
+        return new IndexOutputWriter(new IncrementalChecksumSequentialWriter(file, writerOption));
+    }
+
+    public IndexInput openInput(FileHandle handle)
+    {
+        return IndexInputReader.create(handle);
+    }
+
+    @SuppressWarnings({"resource", "RedundantSuppression"})
+    public IndexInput openBlockingInput(File file)
+    {
+        FileHandle fileHandle = new FileHandle.Builder(file).complete();
+        RandomAccessReader randomReader = fileHandle.createReader();
+
+        return IndexInputReader.create(randomReader, fileHandle::close);
+    }
+
+    public interface ChecksumWriter
+    {
+        long getChecksum();
+    }
+
+    static class IncrementalChecksumSequentialWriter extends SequentialWriter implements ChecksumWriter
+    {
+        private final CRC32 checksum = new CRC32();
+
+        IncrementalChecksumSequentialWriter(File file, SequentialWriterOption writerOption)
+        {
+            super(file, writerOption);
+        }
+
+        @Override
+        public void writeByte(int b) throws IOException
+        {
+            super.writeByte(b);
+            checksum.update(b);
+        }
+
+        @Override
+        public void write(byte[] b) throws IOException
+        {
+            super.write(b);
+            checksum.update(b);
+        }
+
+        @Override
+        public void write(byte[] b, int off, int len) throws IOException
+        {
+            super.write(b, off, len);
+            checksum.update(b, off, len);
+        }
+
+        @Override
+        public void writeChar(int v) throws IOException
+        {
+            super.writeChar(v);
+            addTochecksum(v, 2);
+        }
+
+        @Override
+        public void writeInt(int v) throws IOException
+        {
+            super.writeInt(v);
+            addTochecksum(v, 4);
+        }
+
+        @Override
+        public void writeLong(long v) throws IOException
+        {
+            super.writeLong(v);
+            addTochecksum(v, 8);
+        }
+
+        public long getChecksum()
+        {
+            return checksum.getValue();
+        }
+
+        private void addTochecksum(long bytes, int count)
+        {
+            int origCount = count;
+            if (ByteOrder.BIG_ENDIAN == buffer.order())
+                while (count > 0) checksum.update((int) (bytes >>> (8 * --count)));
+            else
+                while (count > 0) checksum.update((int) (bytes >>> (8 * (origCount - count--))));
+        }
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/io/IndexInputReader.java b/src/java/org/apache/cassandra/index/sai/disk/io/IndexInputReader.java
new file mode 100644
index 0000000000..00a862e53a
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/io/IndexInputReader.java
@@ -0,0 +1,141 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk.io;
+
+
+import java.io.IOException;
+
+import org.apache.cassandra.io.util.FileHandle;
+import org.apache.cassandra.io.util.RandomAccessReader;
+import org.apache.lucene.store.DataInput;
+import org.apache.lucene.store.IndexInput;
+
+/**
+ * This is a wrapper over a Cassandra {@link RandomAccessReader} that provides an {@link IndexInput}
+ * interface for Lucene classes that need {@link IndexInput}. This is an optimisation because the
+ * Lucene {@link DataInput} reads bytes one at a time whereas the {@link RandomAccessReader} is
+ * optimised to read multibyte objects faster.
+ */
+public class IndexInputReader extends IndexInput
+{
+    private final RandomAccessReader input;
+    private final Runnable doOnClose;
+
+    private IndexInputReader(RandomAccessReader input, Runnable doOnClose)
+    {
+        super(input.getPath());
+        this.input = input;
+        this.doOnClose = doOnClose;
+    }
+
+    public static IndexInputReader create(RandomAccessReader input)
+    {
+        return new IndexInputReader(input, () -> {});
+    }
+
+    public static IndexInputReader create(RandomAccessReader input, Runnable doOnClose)
+    {
+        return new IndexInputReader(input, doOnClose);
+    }
+
+    @SuppressWarnings({"resource", "RedundantSuppression"})
+    public static IndexInputReader create(FileHandle handle)
+    {
+        RandomAccessReader reader = handle.createReader();
+        return new IndexInputReader(reader, () -> {});
+    }
+
+    @Override
+    public byte readByte() throws IOException
+    {
+        return input.readByte();
+    }
+
+    @Override
+    public void readBytes(byte[] bytes, int off, int len) throws IOException
+    {
+        input.readFully(bytes, off, len);
+    }
+
+    /**
+     * Using {@link RandomAccessReader#readShort()} directly is faster than {@link DataInput#readShort()} which calls
+     * {@link DataInput#readByte()} one by one
+     */
+    @Override
+    public short readShort() throws IOException
+    {
+        return input.readShort();
+    }
+
+    /**
+     * Using {@link RandomAccessReader#readInt()} directly is faster than {@link DataInput#readInt()} which
+     * calls {@link DataInput#readByte()} one by one
+     */
+    @Override
+    public int readInt() throws IOException
+    {
+        return input.readInt();
+    }
+
+    /**
+     * Using {@link RandomAccessReader#readLong()} directly is faster than {@link DataInput#readLong()} which
+     * calls {@link DataInput#readByte()} one by one
+     */
+    @Override
+    public long readLong() throws IOException
+    {
+        return input.readLong();
+    }
+
+    @Override
+    public void close()
+    {
+        try
+        {
+            input.close();
+        }
+        finally
+        {
+            doOnClose.run();
+        }
+    }
+
+    @Override
+    public long getFilePointer()
+    {
+        return input.getFilePointer();
+    }
+
+    @Override
+    public void seek(long position)
+    {
+        input.seek(position);
+    }
+
+    @Override
+    public long length()
+    {
+        return input.length();
+    }
+
+    @Override
+    public IndexInput slice(String sliceDescription, long offset, long length)
+    {
+        throw new UnsupportedOperationException("Slice operations are not supported");
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/io/IndexOutputWriter.java b/src/java/org/apache/cassandra/index/sai/disk/io/IndexOutputWriter.java
new file mode 100644
index 0000000000..4208048cf6
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/io/IndexOutputWriter.java
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk.io;
+
+import java.io.IOException;
+import javax.annotation.concurrent.NotThreadSafe;
+
+import com.google.common.base.MoreObjects;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.io.util.SequentialWriter;
+import org.apache.lucene.store.IndexOutput;
+
+/**
+ * This is a wrapper over a Cassandra {@link SequentialWriter} that provides a Lucene {@link IndexOutput}
+ * interface for the Lucene index writers.
+ */
+@NotThreadSafe
+public class IndexOutputWriter extends IndexOutput
+{
+    private static final Logger logger = LoggerFactory.getLogger(IndexOutputWriter.class);
+
+    private final SequentialWriter out;
+    private boolean closed;
+
+    public IndexOutputWriter(SequentialWriter out)
+    {
+        super(out.getPath(), out.getPath());
+        this.out = out;
+    }
+
+    public void skipBytes(long length) throws IOException
+    {
+        this.out.skipBytes(length);
+    }
+
+    public File getFile()
+    {
+        return new File(out.getPath());
+    }
+
+    @Override
+    public long getChecksum()
+    {
+        return ((IndexFileUtils.ChecksumWriter)out).getChecksum();
+    }
+
+    @Override
+    public long getFilePointer()
+    {
+        return out.position();
+    }
+
+    @Override
+    public void writeBytes(byte[] bytes, int offset, int len) throws IOException
+    {
+        out.write(bytes, offset, len);
+    }
+
+    @Override
+    public void writeByte(byte b) throws IOException
+    {
+        out.writeByte(b);
+    }
+
+    @Override
+    public void close()
+    {
+        // IndexOutput#close contract allows any output to be closed multiple times,
+        // and Lucene does it in few places. SequentialWriter can be closed once.
+        if (!closed)
+        {
+            if (logger.isTraceEnabled())
+            {
+                logger.trace("Closing index output: {}", this);
+            }
+
+            // The writer should sync its contents to disk before closing...
+            out.close();
+            closed = true;
+        }
+    }
+
+    @Override
+    public String toString()
+    {
+        return MoreObjects.toStringHelper(this)
+                          .add("path", out.getPath())
+                          .add("bytesWritten", getFilePointer())
+                          .add("crc", getChecksum())
+                          .toString();
+    }
+
+    /**
+     * Returns {@link SequentialWriter} associated with this writer. Convenient when interacting with Cassandra codebase to
+     * write files to disk. Note that all bytes written to the returned writer will still contribute to the checksum.
+     *
+     * @return {@link SequentialWriter} associated with this writer
+     */
+    public SequentialWriter asSequentialWriter()
+    {
+        return out;
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/io/RAMIndexOutput.java b/src/java/org/apache/cassandra/index/sai/disk/io/RAMIndexOutput.java
new file mode 100644
index 0000000000..709f4e6d40
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/io/RAMIndexOutput.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk.io;
+
+import java.io.IOException;
+
+import javax.annotation.concurrent.NotThreadSafe;
+
+import org.apache.lucene.store.GrowableByteArrayDataOutput;
+import org.apache.lucene.store.IndexOutput;
+import org.apache.lucene.util.BytesRef;
+
+/**
+ * A replacement for {@link org.apache.lucene.store.RAMOutputStream}
+ * that doesn't use monitor locks.
+ */
+@NotThreadSafe
+public class RAMIndexOutput extends IndexOutput
+{
+    private final GrowableByteArrayDataOutput out;
+
+    public RAMIndexOutput(String name)
+    {
+        super("", name);
+        //TODO CASSANDRA-18280 to investigate the initial size allocation
+        out = new GrowableByteArrayDataOutput(128);
+    }
+
+    @Override
+    public long getChecksum()
+    {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public long getFilePointer()
+    {
+        return out.getPosition();
+    }
+
+    @Override
+    public void writeByte(byte b)
+    {
+        out.writeByte(b);
+    }
+
+    @Override
+    public void writeBytes(byte[] bytes, int offset, int len)
+    {
+        out.writeBytes(bytes, offset, len);
+    }
+
+    public void writeTo(IndexOutput externalOut) throws IOException
+    {
+        externalOut.writeBytes(out.getBytes(), 0, out.getPosition());
+    }
+
+    public BytesRef getBytes()
+    {
+        return new BytesRef(out.getBytes(), 0, out.getPosition());
+    }
+
+    public void reset()
+    {
+        out.reset();
+    }
+
+    @Override
+    public void close()
+    {}
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/io/SeekingRandomAccessInput.java b/src/java/org/apache/cassandra/index/sai/disk/io/SeekingRandomAccessInput.java
new file mode 100644
index 0000000000..fcc5baed9d
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/io/SeekingRandomAccessInput.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk.io;
+
+import java.io.IOException;
+
+import org.apache.lucene.store.IndexInput;
+import org.apache.lucene.store.RandomAccessInput;
+
+/**
+ * {@link IndexInput} adapter that exposes it as a {@link RandomAccessInput}.
+ */
+public class SeekingRandomAccessInput implements RandomAccessInput
+{
+    private final IndexInput in;
+
+    public SeekingRandomAccessInput(IndexInput in)
+    {
+        this.in = in;
+    }
+
+    @Override
+    public byte readByte(long pos) throws IOException
+    {
+        in.seek(pos);
+        return in.readByte();
+    }
+
+    @Override
+    public short readShort(long pos) throws IOException
+    {
+        in.seek(pos);
+        return in.readShort();
+    }
+
+    @Override
+    public int readInt(long pos) throws IOException
+    {
+        in.seek(pos);
+        return in.readInt();
+    }
+
+    @Override
+    public long readLong(long pos) throws IOException
+    {
+        in.seek(pos);
+        return in.readLong();
+    }
+
+    @Override
+    public String toString()
+    {
+        return "SeekingRandomAccessInput(" + in + ')';
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/DirectReaders.java b/src/java/org/apache/cassandra/index/sai/disk/v1/DirectReaders.java
new file mode 100644
index 0000000000..5d5ebce539
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/DirectReaders.java
@@ -0,0 +1,250 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk.v1;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import com.google.common.collect.Sets;
+
+import org.apache.lucene.store.RandomAccessInput;
+
+public class DirectReaders
+{
+    public static final Set<Integer> SUPPORTED_BITS_PER_VALUE = Sets.newHashSet(0, 1, 2, 4, 8, 12, 16, 20, 24, 28, 32, 40, 48, 56, 64);
+    public static final String SUPPORTED_BITS_PER_VALUE_STRING = SUPPORTED_BITS_PER_VALUE.stream()
+                                                                                         .map(i -> Integer.toString(i))
+                                                                                         .collect(Collectors.joining(", "));
+
+    public interface Reader
+    {
+        /**
+         * Returns the indexed delta value from the block starting at offset.
+         * This is implemented for each of the supported bits-per-value where
+         * each implementation is responsible for decoding the bits.
+         */
+        long get(RandomAccessInput in, long offset, long index);
+    }
+
+    public static Reader getReaderForBitsPerValue(byte bitsPerValue)
+    {
+        switch (bitsPerValue)
+        {
+            case 0:
+                return READER_0;
+            case 1:
+                return READER_1;
+            case 2:
+                return READER_2;
+            case 4:
+                return READER_4;
+            case 8:
+                return READER_8;
+            case 12:
+                return READER_12;
+            case 16:
+                return READER_16;
+            case 20:
+                return READER_20;
+            case 24:
+                return READER_24;
+            case 28:
+                return READER_28;
+            case 32:
+                return READER_32;
+            case 40:
+                return READER_40;
+            case 48:
+                return READER_48;
+            case 56:
+                return READER_56;
+            case 64:
+                return READER_64;
+            default:
+                throw new IllegalArgumentException("unsupported bitsPerValue: " + bitsPerValue);
+        }
+    }
+
+    private static final Reader READER_0 = (in, offset, index) -> 0;
+
+    private static final Reader READER_1 = (in, offset, index) -> {
+        try
+        {
+            int shift = 7 - (int) (index & 7);
+            return (in.readByte(offset + (index >>> 3)) >>> shift) & 0x1;
+        }
+        catch (IOException e)
+        {
+            throw new UncheckedIOException(e);
+        }
+    };
+
+    private static final Reader READER_2 = (in, offset, index) -> {
+        try
+        {
+            int shift = (3 - (int) (index & 3)) << 1;
+            return (in.readByte(offset + (index >>> 2)) >>> shift) & 0x3;
+        }
+        catch (IOException e)
+        {
+            throw new UncheckedIOException(e);
+        }
+    };
+
+    private static final Reader READER_4 = (in, offset, index) -> {
+        try
+        {
+            int shift = (int) ((index + 1) & 1) << 2;
+            return (in.readByte(offset + (index >>> 1)) >>> shift) & 0xF;
+        }
+        catch (IOException e)
+        {
+            throw new UncheckedIOException(e);
+        }
+    };
+
+    private static final Reader READER_8 = (in, offset, index) -> {
+        try
+        {
+            return in.readByte(offset + index) & 0xFF;
+        }
+        catch (IOException e)
+        {
+            throw new UncheckedIOException(e);
+        }
+    };
+
+    private static final Reader READER_12 = (in, offset, index) -> {
+        try
+        {
+            long o = (index * 12) >>> 3;
+            int shift = (int) ((index + 1) & 1) << 2;
+            return (in.readShort(offset + o) >>> shift) & 0xFFF;
+        }
+        catch (IOException e)
+        {
+            throw new UncheckedIOException(e);
+        }
+    };
+
+    private static final Reader READER_16 = (in, offset, index) -> {
+        try
+        {
+            return in.readShort(offset + (index << 1)) & 0xFFFF;
+        }
+        catch (IOException e)
+        {
+            throw new UncheckedIOException(e);
+        }
+    };
+
+    private static final Reader READER_20 = (in, offset, index) -> {
+        try
+        {
+            long o = (index * 20) >>> 3;
+            int v = in.readInt(offset + o) >>> 8;
+            int shift = (int) ((index + 1) & 1) << 2;
+            return (v >>> shift) & 0xFFFFF;
+        }
+        catch (IOException e)
+        {
+            throw new UncheckedIOException(e);
+        }
+    };
+
+    private static final Reader READER_24 = (in, offset, index) -> {
+        try
+        {
+            return in.readInt(offset + index * 3) >>> 8;
+        }
+        catch (IOException e)
+        {
+            throw new UncheckedIOException(e);
+        }
+    };
+
+    private static final Reader READER_28 = (in, offset, index) -> {
+        try
+        {
+            long o = (index * 28) >>> 3;
+            int shift = (int) ((index + 1) & 1) << 2;
+            return (in.readInt(offset + o) >>> shift) & 0xFFFFFFFL;
+        }
+        catch (IOException e)
+        {
+            throw new UncheckedIOException(e);
+        }
+    };
+
+    private static final Reader READER_32 = (in, offset, index) -> {
+        try
+        {
+            return in.readInt(offset + (index << 2)) & 0xFFFFFFFFL;
+        }
+        catch (IOException e)
+        {
+            throw new UncheckedIOException(e);
+        }
+    };
+
+    private static final Reader READER_40 = (in, offset, index) -> {
+        try
+        {
+            return in.readLong(offset + index * 5) >>> 24;
+        }
+        catch (IOException e)
+        {
+            throw new UncheckedIOException(e);
+        }
+    };
+
+    private static final Reader READER_48 = (in, offset, index) -> {
+        try
+        {
+            return in.readLong(offset + index * 6) >>> 16;
+        }
+        catch (IOException e)
+        {
+            throw new UncheckedIOException(e);
+        }
+    };
+
+    private static final Reader READER_56 = (in, offset, index) -> {
+        try
+        {
+            return in.readLong(offset + index * 7) >>> 8;
+        }
+        catch (IOException e)
+        {
+            throw new UncheckedIOException(e);
+        }
+    };
+
+    private static final Reader READER_64 = (in, offset, index) -> {
+        try
+        {
+            return in.readLong(offset + (index << 3));
+        }
+        catch (IOException e)
+        {
+            throw new UncheckedIOException(e);
+        }
+    };
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/LongArray.java b/src/java/org/apache/cassandra/index/sai/disk/v1/LongArray.java
new file mode 100644
index 0000000000..2c2a1fc39b
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/LongArray.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk.v1;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.util.function.Supplier;
+import javax.annotation.concurrent.NotThreadSafe;
+
+/**
+ * Abstraction over a long-indexed array of longs.
+ */
+public interface LongArray extends Closeable
+{
+    /**
+     * Get value at {@code idx}.
+     */
+    long get(long idx);
+
+    /**
+     * Get array length.
+     */
+    long length();
+
+    @Override
+    default void close() throws IOException { }
+
+    @NotThreadSafe
+    class DeferredLongArray implements LongArray
+    {
+        private final Supplier<LongArray> supplier;
+        private LongArray longArray;
+        private boolean opened = false;
+
+        public DeferredLongArray(Supplier<LongArray> supplier)
+        {
+            this.supplier = supplier;
+        }
+
+        @Override
+        public long get(long idx)
+        {
+            open();
+            return longArray.get(idx);
+        }
+
+        @Override
+        public long length()
+        {
+            open();
+            return longArray.length();
+        }
+
+        @Override
+        public void close() throws IOException
+        {
+            if (opened)
+                longArray.close();
+        }
+
+        private void open()
+        {
+            if (!opened)
+            {
+                longArray = supplier.get();
+                opened = true;
+            }
+        }
+    }
+
+    interface Factory
+    {
+        LongArray open();
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/MemtableIndexWriter.java b/src/java/org/apache/cassandra/index/sai/disk/v1/MemtableIndexWriter.java
new file mode 100644
index 0000000000..6a497fbb33
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/MemtableIndexWriter.java
@@ -0,0 +1,179 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk.v1;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.concurrent.TimeUnit;
+
+import com.google.common.base.Stopwatch;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.carrotsearch.hppc.LongArrayList;
+import org.apache.cassandra.db.DecoratedKey;
+import org.apache.cassandra.db.marshal.AbstractType;
+import org.apache.cassandra.db.rows.Row;
+import org.apache.cassandra.index.sai.IndexContext;
+import org.apache.cassandra.index.sai.disk.PerColumnIndexWriter;
+import org.apache.cassandra.index.sai.disk.RowMapping;
+import org.apache.cassandra.index.sai.disk.format.IndexComponent;
+import org.apache.cassandra.index.sai.disk.format.IndexDescriptor;
+import org.apache.cassandra.index.sai.disk.v1.segment.SegmentMetadata;
+import org.apache.cassandra.index.sai.disk.v1.trie.LiteralIndexWriter;
+import org.apache.cassandra.index.sai.memory.MemtableIndex;
+import org.apache.cassandra.index.sai.memory.MemtableTermsIterator;
+import org.apache.cassandra.index.sai.utils.PrimaryKey;
+import org.apache.cassandra.index.sai.utils.TypeUtil;
+import org.apache.cassandra.utils.Pair;
+import org.apache.cassandra.utils.bytecomparable.ByteComparable;
+
+/**
+ * Column index writer that flushes indexed data directly from the corresponding Memtable index, without buffering index
+ * data in memory.
+ */
+public class MemtableIndexWriter implements PerColumnIndexWriter
+{
+    private static final Logger logger = LoggerFactory.getLogger(MemtableIndexWriter.class);
+
+    private final IndexDescriptor indexDescriptor;
+    private final IndexContext indexContext;
+    private final MemtableIndex memtable;
+    private final RowMapping rowMapping;
+
+    public MemtableIndexWriter(MemtableIndex memtable,
+                               IndexDescriptor indexDescriptor,
+                               IndexContext indexContext,
+                               RowMapping rowMapping)
+    {
+        assert rowMapping != null && rowMapping != RowMapping.DUMMY : "Row mapping must exist during FLUSH.";
+
+        this.indexDescriptor = indexDescriptor;
+        this.indexContext = indexContext;
+        this.memtable = memtable;
+        this.rowMapping = rowMapping;
+    }
+
+    @Override
+    public void addRow(PrimaryKey key, Row row, long sstableRowId)
+    {
+        // Memtable indexes are flushed directly to disk with the aid of a mapping between primary
+        // keys and row IDs in the flushing SSTable. This writer, therefore, does nothing in
+        // response to the flushing of individual rows.
+    }
+
+    @Override
+    public void abort(Throwable cause)
+    {
+        logger.warn(indexContext.logMessage("Aborting index memtable flush for {}..."), indexDescriptor.sstableDescriptor, cause);
+        indexDescriptor.deleteColumnIndex(indexContext);
+    }
+
+    @Override
+    public void complete(Stopwatch stopwatch) throws IOException
+    {
+        assert rowMapping.isComplete() : "Cannot complete the memtable index writer because the row mapping is not complete";
+
+        long start = stopwatch.elapsed(TimeUnit.MILLISECONDS);
+
+        try
+        {
+            if (!rowMapping.hasRows() || memtable == null || memtable.isEmpty())
+            {
+                logger.debug(indexContext.logMessage("No indexed rows to flush from SSTable {}."), indexDescriptor.sstableDescriptor);
+                // Write a completion marker even though we haven't written anything to the index,
+                // so we won't try to build the index again for the SSTable
+                indexDescriptor.createComponentOnDisk(IndexComponent.COLUMN_COMPLETION_MARKER, indexContext);
+                return;
+            }
+
+            final DecoratedKey minKey = rowMapping.minKey.partitionKey();
+            final DecoratedKey maxKey = rowMapping.maxKey.partitionKey();
+
+            final Iterator<Pair<ByteComparable, LongArrayList>> iterator = rowMapping.merge(memtable);
+
+            try (MemtableTermsIterator terms = new MemtableTermsIterator(memtable.getMinTerm(), memtable.getMaxTerm(), iterator))
+            {
+                long cellCount = flush(minKey, maxKey, indexContext.getValidator(), terms);
+
+                indexDescriptor.createComponentOnDisk(IndexComponent.COLUMN_COMPLETION_MARKER, indexContext);
+
+                indexContext.getIndexMetrics().memtableIndexFlushCount.inc();
+
+                long elapsed = stopwatch.elapsed(TimeUnit.MILLISECONDS);
+
+                logger.debug(indexContext.logMessage("Completed flushing {} memtable index cells to SSTable {}. Duration: {} ms. Total elapsed: {} ms"),
+                             cellCount, indexDescriptor.sstableDescriptor, elapsed - start, elapsed);
+
+                indexContext.getIndexMetrics().memtableFlushCellsPerSecond.update((long) (cellCount * 1000.0 / Math.max(1, elapsed - start)));
+            }
+        }
+        catch (Throwable t)
+        {
+            logger.error(indexContext.logMessage("Error while flushing index {}"), t.getMessage(), t);
+            indexContext.getIndexMetrics().memtableIndexFlushErrors.inc();
+
+            throw t;
+        }
+    }
+
+    private long flush(DecoratedKey minKey,
+                       DecoratedKey maxKey,
+                       AbstractType<?> termComparator,
+                       MemtableTermsIterator terms) throws IOException
+    {
+        long numRows = 0;
+        SegmentMetadata.ComponentMetadataMap indexMetas = null;
+
+        if (TypeUtil.isLiteral(termComparator))
+        {
+            try (LiteralIndexWriter writer = new LiteralIndexWriter(indexDescriptor, indexContext))
+            {
+                indexMetas = writer.writeCompleteSegment(terms);
+                numRows = writer.getPostingsCount();
+            }
+        }
+
+        // If no rows were written we need to delete any created column index components
+        // so that the index is correctly identified as being empty (only having a completion marker)
+        if (numRows == 0)
+        {
+            indexDescriptor.deleteColumnIndex(indexContext);
+            return 0;
+        }
+
+        // During index memtable flush, the data is sorted based on terms.
+        SegmentMetadata metadata = new SegmentMetadata(0,
+                                                       numRows,
+                                                       terms.getMinSSTableRowId(),
+                                                       terms.getMaxSSTableRowId(),
+                                                       indexDescriptor.primaryKeyFactory.createPartitionKeyOnly(minKey),
+                                                       indexDescriptor.primaryKeyFactory.createPartitionKeyOnly(maxKey),
+                                                       terms.getMinTerm(),
+                                                       terms.getMaxTerm(),
+                                                       indexMetas);
+
+        try (MetadataWriter writer = new MetadataWriter(indexDescriptor.openPerIndexOutput(IndexComponent.META, indexContext)))
+        {
+            SegmentMetadata.write(writer, Collections.singletonList(metadata));
+        }
+
+        return numRows;
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/MetadataSource.java b/src/java/org/apache/cassandra/index/sai/disk/v1/MetadataSource.java
new file mode 100644
index 0000000000..b8b7fb66cc
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/MetadataSource.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk.v1;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import javax.annotation.concurrent.NotThreadSafe;
+
+import org.apache.cassandra.index.sai.IndexContext;
+import org.apache.cassandra.index.sai.disk.format.IndexComponent;
+import org.apache.cassandra.index.sai.disk.format.IndexDescriptor;
+import org.apache.lucene.store.BufferedChecksumIndexInput;
+import org.apache.lucene.store.ByteArrayIndexInput;
+import org.apache.lucene.store.IndexInput;
+import org.apache.lucene.util.BytesRef;
+
+@NotThreadSafe
+public class MetadataSource
+{
+    private final Map<String, BytesRef> components;
+
+    private MetadataSource(Map<String, BytesRef> components)
+    {
+        this.components = components;
+    }
+
+    public static MetadataSource loadGroupMetadata(IndexDescriptor indexDescriptor) throws IOException
+    {
+        return MetadataSource.load(indexDescriptor.openPerSSTableInput(IndexComponent.GROUP_META));
+    }
+
+    public static MetadataSource loadColumnMetadata(IndexDescriptor indexDescriptor, IndexContext indexContext) throws IOException
+    {
+        return MetadataSource.load(indexDescriptor.openPerIndexInput(IndexComponent.META, indexContext));
+    }
+
+    private static MetadataSource load(IndexInput indexInput) throws IOException
+    {
+        Map<String, BytesRef> components = new HashMap<>();
+
+        try (BufferedChecksumIndexInput input = new BufferedChecksumIndexInput(indexInput))
+        {
+            SAICodecUtils.checkHeader(input);
+            final int num = input.readInt();
+
+            for (int x = 0; x < num; x++)
+            {
+                if (input.length() == input.getFilePointer())
+                {
+                    // we should never get here, because we always add footer to the file
+                    throw new IllegalStateException("Unexpected EOF in " + input);
+                }
+
+                final String name = input.readString();
+                final int length = input.readInt();
+                final byte[] bytes = new byte[length];
+                input.readBytes(bytes, 0, length);
+
+                components.put(name, new BytesRef(bytes));
+            }
+
+            SAICodecUtils.checkFooter(input);
+        }
+
+        return new MetadataSource(components);
+    }
+
+    public IndexInput get(String name)
+    {
+        BytesRef bytes = components.get(name);
+
+        if (bytes == null)
+        {
+            throw new IllegalArgumentException(String.format("Could not find component '%s'. Available properties are %s.",
+                                                             name, components.keySet()));
+        }
+
+        return new ByteArrayIndexInput(name, bytes.bytes);
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/MetadataWriter.java b/src/java/org/apache/cassandra/index/sai/disk/v1/MetadataWriter.java
new file mode 100644
index 0000000000..b995f4ef17
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/MetadataWriter.java
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk.v1;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import javax.annotation.concurrent.NotThreadSafe;
+
+import org.apache.cassandra.index.sai.disk.io.RAMIndexOutput;
+import org.apache.lucene.store.IndexOutput;
+import org.apache.lucene.util.BytesRef;
+
+@NotThreadSafe
+public class MetadataWriter implements Closeable
+{
+    private final IndexOutput output;
+    private final Map<String, BytesRef> map = new HashMap<>();
+
+    public MetadataWriter(IndexOutput output)
+    {
+        this.output = output;
+    }
+
+    public Builder builder(String name)
+    {
+        return new Builder(name);
+    }
+
+    public class Builder extends RAMIndexOutput implements Closeable
+    {
+        private Builder(String name)
+        {
+            super(name);
+        }
+
+        @Override
+        public void close()
+        {
+            map.put(getName(), getBytes());
+        }
+    }
+
+    private void finish() throws IOException
+    {
+        SAICodecUtils.writeHeader(output);
+        output.writeInt(map.size());
+        for (Map.Entry<String, BytesRef> entry : map.entrySet())
+        {
+            output.writeString(entry.getKey());
+            output.writeInt(entry.getValue().length);
+            output.writeBytes(entry.getValue().bytes, entry.getValue().offset, entry.getValue().length);
+        }
+        SAICodecUtils.writeFooter(output);
+    }
+
+    @Override
+    public void close() throws IOException
+    {
+        try
+        {
+            finish();
+        }
+        finally
+        {
+            output.close();
+        }
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/PerColumnIndexFiles.java b/src/java/org/apache/cassandra/index/sai/disk/v1/PerColumnIndexFiles.java
new file mode 100644
index 0000000000..7d129ba28f
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/PerColumnIndexFiles.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.index.sai.disk.v1;
+
+import java.io.Closeable;
+import java.util.EnumMap;
+import java.util.Map;
+
+import org.apache.cassandra.index.sai.IndexContext;
+import org.apache.cassandra.index.sai.disk.format.IndexComponent;
+import org.apache.cassandra.index.sai.disk.format.IndexDescriptor;
+import org.apache.cassandra.index.sai.utils.TypeUtil;
+import org.apache.cassandra.io.util.FileHandle;
+import org.apache.cassandra.io.util.FileUtils;
+
+/**
+ * Maintains a mapping of {@link IndexComponent}s to associated {@link FileHandle}s for
+ * read operations on the components. Users of this class are returned copies of the
+ * {@link FileHandle}s using {@link FileHandle#sharedCopy()} so returned handles still
+ * need to be closed by the user.
+ */
+public class PerColumnIndexFiles implements Closeable
+{
+    private final Map<IndexComponent, FileHandle> files = new EnumMap<>(IndexComponent.class);
+    private final IndexDescriptor indexDescriptor;
+    private final IndexContext indexContext;
+
+    public PerColumnIndexFiles(IndexDescriptor indexDescriptor, IndexContext indexContext)
+    {
+        this.indexDescriptor = indexDescriptor;
+        this.indexContext = indexContext;
+        if (TypeUtil.isLiteral(indexContext.getValidator()))
+        {
+            files.put(IndexComponent.POSTING_LISTS, indexDescriptor.createPerIndexFileHandle(IndexComponent.POSTING_LISTS, indexContext));
+            files.put(IndexComponent.TERMS_DATA, indexDescriptor.createPerIndexFileHandle(IndexComponent.TERMS_DATA, indexContext));
+        }
+    }
+
+    public FileHandle termsData()
+    {
+        return getFile(IndexComponent.TERMS_DATA);
+    }
+
+    public FileHandle postingLists()
+    {
+        return getFile(IndexComponent.POSTING_LISTS);
+    }
+
+    @SuppressWarnings({"resource", "RedundantSuppression"})
+    private FileHandle getFile(IndexComponent indexComponent)
+    {
+        FileHandle file = files.get(indexComponent);
+        if (file == null)
+            throw new IllegalArgumentException(String.format(indexContext.logMessage("Component %s not found for SSTable %s"),
+                                                             indexComponent, indexDescriptor.sstableDescriptor));
+
+        return file.sharedCopy();
+    }
+
+    @Override
+    public void close()
+    {
+        FileUtils.closeQuietly(files.values());
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/RowAwarePrimaryKeyMap.java b/src/java/org/apache/cassandra/index/sai/disk/v1/RowAwarePrimaryKeyMap.java
new file mode 100644
index 0000000000..1319006d21
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/RowAwarePrimaryKeyMap.java
@@ -0,0 +1,198 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.index.sai.disk.v1;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import javax.annotation.concurrent.NotThreadSafe;
+import javax.annotation.concurrent.ThreadSafe;
+
+import org.apache.cassandra.db.BufferDecoratedKey;
+import org.apache.cassandra.db.Clustering;
+import org.apache.cassandra.db.ClusteringComparator;
+import org.apache.cassandra.db.DecoratedKey;
+import org.apache.cassandra.db.marshal.ByteBufferAccessor;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.index.sai.disk.PrimaryKeyMap;
+import org.apache.cassandra.index.sai.disk.format.IndexComponent;
+import org.apache.cassandra.index.sai.disk.format.IndexDescriptor;
+import org.apache.cassandra.index.sai.disk.v1.bitpack.BlockPackedReader;
+import org.apache.cassandra.index.sai.disk.v1.bitpack.NumericValuesMeta;
+import org.apache.cassandra.index.sai.disk.v1.sortedterms.SortedTermsMeta;
+import org.apache.cassandra.index.sai.disk.v1.sortedterms.SortedTermsReader;
+import org.apache.cassandra.index.sai.disk.v1.trie.TriePrefixSearcher;
+import org.apache.cassandra.index.sai.utils.PrimaryKey;
+import org.apache.cassandra.io.sstable.format.SSTableReader;
+import org.apache.cassandra.io.util.FileHandle;
+import org.apache.cassandra.io.util.FileUtils;
+import org.apache.cassandra.utils.Throwables;
+import org.apache.cassandra.utils.bytecomparable.ByteComparable;
+import org.apache.cassandra.utils.bytecomparable.ByteSource;
+import org.apache.cassandra.utils.bytecomparable.ByteSourceInverse;
+
+/**
+ * A row-aware {@link PrimaryKeyMap}
+ *
+ * This uses the following on-disk structures:
+ * <ul>
+ *     <li>Block-packed structure for rowId to token lookups using {@link BlockPackedReader}.
+ *     Uses component {@link IndexComponent#TOKEN_VALUES} </li>
+ *     <li>A sorted-terms structure for rowId to {@link PrimaryKey} and {@link PrimaryKey} to rowId lookups using
+ *     {@link SortedTermsReader} and {@link TriePrefixSearcher}. Uses components {@link IndexComponent#PRIMARY_KEY_TRIE},
+ *     {@link IndexComponent#PRIMARY_KEY_BLOCKS} and {@link IndexComponent#PRIMARY_KEY_BLOCK_OFFSETS}</li>
+ * </ul>
+ *
+ * While the {@link RowAwarePrimaryKeyMapFactory} is threadsafe, individual instances of the {@link RowAwarePrimaryKeyMap}
+ * are not.
+ */
+@NotThreadSafe
+public class RowAwarePrimaryKeyMap implements PrimaryKeyMap
+{
+    @ThreadSafe
+    public static class RowAwarePrimaryKeyMapFactory implements Factory
+    {
+        private final LongArray.Factory tokenReaderFactory;
+        private final SortedTermsReader sortedTermsReader;
+        private final SortedTermsMeta sortedTermsMeta;
+        private FileHandle tokensFile = null;
+        private FileHandle primaryKeyBlockOffsetsFile = null;
+        private FileHandle primaryKeyBlocksFile = null;
+        private FileHandle primaryKeyTrieFile = null;
+        private final IPartitioner partitioner;
+        private final ClusteringComparator clusteringComparator;
+        private final PrimaryKey.Factory primaryKeyFactory;
+
+        public RowAwarePrimaryKeyMapFactory(IndexDescriptor indexDescriptor, SSTableReader sstable)
+        {
+            try
+            {
+                MetadataSource metadataSource = MetadataSource.loadGroupMetadata(indexDescriptor);
+                NumericValuesMeta tokensMeta = new NumericValuesMeta(metadataSource.get(indexDescriptor.componentName(IndexComponent.TOKEN_VALUES)));
+                NumericValuesMeta blockOffsetsMeta = new NumericValuesMeta(metadataSource.get(indexDescriptor.componentName(IndexComponent.PRIMARY_KEY_BLOCK_OFFSETS)));
+                this.sortedTermsMeta = new SortedTermsMeta(metadataSource.get(indexDescriptor.componentName(IndexComponent.PRIMARY_KEY_BLOCKS)));
+                this.tokensFile = indexDescriptor.createPerSSTableFileHandle(IndexComponent.TOKEN_VALUES);
+                this.tokenReaderFactory = new BlockPackedReader(tokensFile, tokensMeta);
+                this.primaryKeyBlockOffsetsFile = indexDescriptor.createPerSSTableFileHandle(IndexComponent.PRIMARY_KEY_BLOCK_OFFSETS);
+                this.primaryKeyBlocksFile = indexDescriptor.createPerSSTableFileHandle(IndexComponent.PRIMARY_KEY_BLOCKS);
+                this.primaryKeyTrieFile = indexDescriptor.createPerSSTableFileHandle(IndexComponent.PRIMARY_KEY_TRIE);
+                this.sortedTermsReader = new SortedTermsReader(primaryKeyBlocksFile, primaryKeyBlockOffsetsFile, sortedTermsMeta, blockOffsetsMeta);
+                this.partitioner = sstable.metadata().partitioner;
+                this.primaryKeyFactory = indexDescriptor.primaryKeyFactory;
+                this.clusteringComparator = indexDescriptor.clusteringComparator;
+            }
+            catch (Throwable t)
+            {
+                throw Throwables.unchecked(Throwables.close(t, Arrays.asList(tokensFile, primaryKeyBlocksFile, primaryKeyBlockOffsetsFile, primaryKeyTrieFile)));
+            }
+        }
+
+        @Override
+        @SuppressWarnings({"resource", "RedundantSuppression"})
+        public PrimaryKeyMap newPerSSTablePrimaryKeyMap() throws IOException
+        {
+            LongArray rowIdToToken = new LongArray.DeferredLongArray(tokenReaderFactory::open);
+            return new RowAwarePrimaryKeyMap(rowIdToToken,
+                                             new TriePrefixSearcher(primaryKeyTrieFile.instantiateRebufferer(null), sortedTermsMeta.trieFilePointer),
+                                             sortedTermsReader.openCursor(),
+                                             partitioner,
+                                             primaryKeyFactory,
+                                             clusteringComparator);
+        }
+
+        @Override
+        public void close()
+        {
+            FileUtils.closeQuietly(Arrays.asList(tokensFile, primaryKeyBlocksFile, primaryKeyBlockOffsetsFile, primaryKeyTrieFile));
+        }
+    }
+
+    private final LongArray rowIdToToken;
+    private final TriePrefixSearcher triePrefixSearcher;
+    private final SortedTermsReader.Cursor cursor;
+    private final IPartitioner partitioner;
+    private final PrimaryKey.Factory primaryKeyFactory;
+    private final ClusteringComparator clusteringComparator;
+    private final ByteBuffer tokenBuffer = ByteBuffer.allocate(Long.BYTES);
+
+    private RowAwarePrimaryKeyMap(LongArray rowIdToToken,
+                                  TriePrefixSearcher triePrefixSearcher,
+                                  SortedTermsReader.Cursor cursor,
+                                  IPartitioner partitioner,
+                                  PrimaryKey.Factory primaryKeyFactory,
+                                  ClusteringComparator clusteringComparator)
+    {
+        this.rowIdToToken = rowIdToToken;
+        this.triePrefixSearcher = triePrefixSearcher;
+        this.cursor = cursor;
+        this.partitioner = partitioner;
+        this.primaryKeyFactory = primaryKeyFactory;
+        this.clusteringComparator = clusteringComparator;
+    }
+
+    @Override
+    public PrimaryKey primaryKeyFromRowId(long sstableRowId)
+    {
+        tokenBuffer.putLong(rowIdToToken.get(sstableRowId));
+        tokenBuffer.rewind();
+        return primaryKeyFactory.createDeferred(partitioner.getTokenFactory().fromByteArray(tokenBuffer), () -> supplier(sstableRowId));
+    }
+
+    @Override
+    public long rowIdFromPrimaryKey(PrimaryKey key)
+    {
+        return triePrefixSearcher.prefixSearch(key.asComparableBytes(ByteComparable.Version.OSS50));
+    }
+
+    @Override
+    public void close()
+    {
+        FileUtils.closeQuietly(Arrays.asList(triePrefixSearcher, cursor, rowIdToToken));
+    }
+
+    private PrimaryKey supplier(long sstableRowId)
+    {
+        try
+        {
+            cursor.seekToPointId(sstableRowId);
+            ByteSource.Peekable peekable = ByteSource.peekable(cursor.term().asComparableBytes(ByteComparable.Version.OSS50));
+
+            Token token = partitioner.getTokenFactory().fromComparableBytes(ByteSourceInverse.nextComponentSource(peekable),
+                                                                            ByteComparable.Version.OSS50);
+            byte[] keyBytes = ByteSourceInverse.getUnescapedBytes(ByteSourceInverse.nextComponentSource(peekable));
+
+            if (keyBytes == null)
+                return primaryKeyFactory.createTokenOnly(token);
+
+            DecoratedKey partitionKey = new BufferDecoratedKey(token, ByteBuffer.wrap(keyBytes));
+
+            Clustering<?> clustering = clusteringComparator.size() == 0
+                                       ? Clustering.EMPTY
+                                       : clusteringComparator.clusteringFromByteComparable(ByteBufferAccessor.instance,
+                                                                                           v -> ByteSourceInverse.nextComponentSource(peekable));
+
+            return primaryKeyFactory.create(partitionKey, clustering);
+        }
+        catch (IOException e)
+        {
+            throw Throwables.cleaned(e);
+        }
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/SAICodecUtils.java b/src/java/org/apache/cassandra/index/sai/disk/v1/SAICodecUtils.java
new file mode 100644
index 0000000000..72d1a55539
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/SAICodecUtils.java
@@ -0,0 +1,259 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.index.sai.disk.v1;
+
+import java.io.IOException;
+
+import org.apache.cassandra.index.sai.disk.format.Version;
+import org.apache.lucene.codecs.CodecUtil;
+import org.apache.lucene.index.CorruptIndexException;
+import org.apache.lucene.store.ChecksumIndexInput;
+import org.apache.lucene.store.DataInput;
+import org.apache.lucene.store.IndexInput;
+import org.apache.lucene.store.IndexOutput;
+
+import static org.apache.lucene.codecs.CodecUtil.CODEC_MAGIC;
+import static org.apache.lucene.codecs.CodecUtil.FOOTER_MAGIC;
+
+public class SAICodecUtils
+{
+    public static final String FOOTER_POINTER = "footerPointer";
+
+    public static void writeHeader(IndexOutput out) throws IOException
+    {
+        out.writeInt(CODEC_MAGIC);
+        out.writeString(Version.LATEST.toString());
+    }
+
+    public static void writeFooter(IndexOutput out) throws IOException
+    {
+        out.writeInt(FOOTER_MAGIC);
+        out.writeInt(0);
+        writeCRC(out);
+    }
+
+    public static void checkHeader(DataInput in) throws IOException
+    {
+        final int actualMagic = in.readInt();
+        if (actualMagic != CODEC_MAGIC)
+        {
+            throw new CorruptIndexException("codec header mismatch: actual header=" + actualMagic + " vs expected header=" + CODEC_MAGIC, in);
+        }
+        final Version actualVersion = Version.parse(in.readString());
+        if (!actualVersion.onOrAfter(Version.EARLIEST))
+        {
+            throw new IOException("Unsupported version: " + actualVersion);
+        }
+    }
+
+    public static void checkFooter(ChecksumIndexInput in) throws IOException
+    {
+        validateFooter(in, false);
+        long actualChecksum = in.getChecksum();
+        long expectedChecksum = readCRC(in);
+        if (expectedChecksum != actualChecksum)
+        {
+            throw new CorruptIndexException("checksum failed (hardware problem?) : expected=" + Long.toHexString(expectedChecksum) +
+                                            " actual=" + Long.toHexString(actualChecksum), in);
+        }
+    }
+
+    public static void validate(IndexInput input) throws IOException
+    {
+        checkHeader(input);
+        validateFooterAndResetPosition(input);
+    }
+
+    public static void validate(IndexInput input, long footerPointer) throws IOException
+    {
+        checkHeader(input);
+
+        long current = input.getFilePointer();
+        input.seek(footerPointer);
+        validateFooter(input, true);
+
+        input.seek(current);
+    }
+
+    public static void validateChecksum(IndexInput input) throws IOException
+    {
+        CodecUtil.checksumEntireFile(input);
+    }
+
+    // Copied from Lucene PackedInts as they are not public
+
+    public static int checkBlockSize(int blockSize, int minBlockSize, int maxBlockSize)
+    {
+        if (blockSize >= minBlockSize && blockSize <= maxBlockSize)
+        {
+            if ((blockSize & blockSize - 1) != 0)
+            {
+                throw new IllegalArgumentException("blockSize must be a power of two, got " + blockSize);
+            }
+            else
+            {
+                return Integer.numberOfTrailingZeros(blockSize);
+            }
+        }
+        else
+        {
+            throw new IllegalArgumentException("blockSize must be >= " + minBlockSize + " and <= " + maxBlockSize + ", got " + blockSize);
+        }
+    }
+
+    public static int numBlocks(long size, int blockSize)
+    {
+        if (size < 0)
+            throw new IllegalArgumentException("size cannot be negative");
+
+        int numBlocks = (int)(size / (long)blockSize) + (size % (long)blockSize == 0L ? 0 : 1);
+        if ((long)numBlocks * (long)blockSize < size)
+        {
+            throw new IllegalArgumentException("size is too large for this block size");
+        }
+        else
+        {
+            return numBlocks;
+        }
+    }
+
+    // Copied from Lucene BlockPackedReaderIterator as they are not public
+
+    /**
+     * Same as DataInput.readVLong but supports negative values
+     */
+    public static long readVLong(DataInput in) throws IOException
+    {
+        byte b = in.readByte();
+        if (b >= 0) return b;
+        long i = b & 0x7FL;
+        b = in.readByte();
+        i |= (b & 0x7FL) << 7;
+        if (b >= 0) return i;
+        b = in.readByte();
+        i |= (b & 0x7FL) << 14;
+        if (b >= 0) return i;
+        b = in.readByte();
+        i |= (b & 0x7FL) << 21;
+        if (b >= 0) return i;
+        b = in.readByte();
+        i |= (b & 0x7FL) << 28;
+        if (b >= 0) return i;
+        b = in.readByte();
+        i |= (b & 0x7FL) << 35;
+        if (b >= 0) return i;
+        b = in.readByte();
+        i |= (b & 0x7FL) << 42;
+        if (b >= 0) return i;
+        b = in.readByte();
+        i |= (b & 0x7FL) << 49;
+        if (b >= 0) return i;
+        b = in.readByte();
+        i |= (b & 0xFFL) << 56;
+        return i;
+    }
+
+    public static void validateFooterAndResetPosition(IndexInput in) throws IOException
+    {
+        long position = in.getFilePointer();
+        long fileLength = in.length();
+        long footerLength = CodecUtil.footerLength();
+        long footerPosition = fileLength - footerLength;
+
+        if (footerPosition < 0)
+        {
+            throw new CorruptIndexException("invalid codec footer (file truncated?): file length=" + fileLength + ", footer length=" + footerLength, in);
+        }
+
+        in.seek(footerPosition);
+        validateFooter(in, false);
+        in.seek(position);
+    }
+
+    /**
+     * Copied from org.apache.lucene.codecs.CodecUtil.validateFooter(IndexInput).
+     *
+     * If the file is segmented then the footer can exist in the middle of the file
+     * so, we shouldn't check that the footer size is correct, we just check that the
+     * footer values are correct.
+     */
+    private static void validateFooter(IndexInput in, boolean segmented) throws IOException
+    {
+        long remaining = in.length() - in.getFilePointer();
+        long expected = CodecUtil.footerLength();
+
+        if (!segmented)
+        {
+            if (remaining < expected)
+            {
+                throw new CorruptIndexException("misplaced codec footer (file truncated?): remaining=" + remaining + ", expected=" + expected + ", fp=" + in.getFilePointer(), in);
+            }
+            else if (remaining > expected)
+            {
+                throw new CorruptIndexException("misplaced codec footer (file extended?): remaining=" + remaining + ", expected=" + expected + ", fp=" + in.getFilePointer(), in);
+            }
+        }
+
+        final int magic = in.readInt();
+
+        if (magic != FOOTER_MAGIC)
+        {
+            throw new CorruptIndexException("codec footer mismatch (file truncated?): actual footer=" + magic + " vs expected footer=" + FOOTER_MAGIC, in);
+        }
+
+        final int algorithmID = in.readInt();
+
+        if (algorithmID != 0)
+        {
+            throw new CorruptIndexException("codec footer mismatch: unknown algorithmID: " + algorithmID, in);
+        }
+    }
+
+    // Copied from Lucene CodecUtil as they are not public
+
+    /**
+     * Writes CRC32 value as a 64-bit long to the output.
+     * @throws IllegalStateException if CRC is formatted incorrectly (wrong bits set)
+     * @throws IOException if an i/o error occurs
+     */
+    private static void writeCRC(IndexOutput output) throws IOException
+    {
+        long value = output.getChecksum();
+        if ((value & 0xFFFFFFFF00000000L) != 0)
+        {
+            throw new IllegalStateException("Illegal CRC-32 checksum: " + value + " (resource=" + output + ')');
+        }
+        output.writeLong(value);
+    }
+
+    /**
+     * Reads CRC32 value as a 64-bit long from the input.
+     * @throws CorruptIndexException if CRC is formatted incorrectly (wrong bits set)
+     * @throws IOException if an i/o error occurs
+     */
+    private static long readCRC(IndexInput input) throws IOException
+    {
+        long value = input.readLong();
+        if ((value & 0xFFFFFFFF00000000L) != 0)
+        {
+            throw new CorruptIndexException("Illegal CRC-32 checksum: " + value, input);
+        }
+        return value;
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/SSTableComponentsWriter.java b/src/java/org/apache/cassandra/index/sai/disk/v1/SSTableComponentsWriter.java
new file mode 100644
index 0000000000..8845f27300
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/SSTableComponentsWriter.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.index.sai.disk.v1;
+
+import java.io.IOException;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.cassandra.index.sai.disk.PerSSTableIndexWriter;
+import org.apache.cassandra.index.sai.disk.format.IndexComponent;
+import org.apache.cassandra.index.sai.disk.format.IndexDescriptor;
+import org.apache.cassandra.index.sai.disk.io.IndexOutputWriter;
+import org.apache.cassandra.index.sai.disk.v1.bitpack.NumericValuesWriter;
+import org.apache.cassandra.index.sai.disk.v1.sortedterms.SortedTermsWriter;
+import org.apache.cassandra.index.sai.utils.PrimaryKey;
+import org.apache.lucene.util.IOUtils;
+
+public class SSTableComponentsWriter implements PerSSTableIndexWriter
+{
+    protected static final Logger logger = LoggerFactory.getLogger(SSTableComponentsWriter.class);
+
+    private final IndexDescriptor indexDescriptor;
+    private final MetadataWriter metadataWriter;
+    private final NumericValuesWriter tokenWriter;
+    private final SortedTermsWriter sortedTermsWriter;
+
+    @SuppressWarnings({"resource", "RedundantSuppression"})
+    public SSTableComponentsWriter(IndexDescriptor indexDescriptor) throws IOException
+    {
+        this.indexDescriptor = indexDescriptor;
+        this.metadataWriter = new MetadataWriter(indexDescriptor.openPerSSTableOutput(IndexComponent.GROUP_META));
+        this.tokenWriter = new NumericValuesWriter(indexDescriptor.componentName(IndexComponent.TOKEN_VALUES),
+                                                   indexDescriptor.openPerSSTableOutput(IndexComponent.TOKEN_VALUES),
+                                                   metadataWriter, false);
+        IndexOutputWriter primaryKeyTrieWriter = indexDescriptor.openPerSSTableOutput(IndexComponent.PRIMARY_KEY_TRIE);
+        IndexOutputWriter primaryKeyBlocksWriter = indexDescriptor.openPerSSTableOutput(IndexComponent.PRIMARY_KEY_BLOCKS);
+        NumericValuesWriter primaryKeyBlockOffsetWriter = new NumericValuesWriter(indexDescriptor.componentName(IndexComponent.PRIMARY_KEY_BLOCK_OFFSETS),
+                                                     indexDescriptor.openPerSSTableOutput(IndexComponent.PRIMARY_KEY_BLOCK_OFFSETS),
+                                                     metadataWriter, true);
+        this.sortedTermsWriter = new SortedTermsWriter(indexDescriptor.componentName(IndexComponent.PRIMARY_KEY_BLOCKS),
+                                                       metadataWriter,
+                                                       primaryKeyBlocksWriter,
+                                                       primaryKeyBlockOffsetWriter,
+                                                       primaryKeyTrieWriter);
+    }
+
+    @Override
+    public void nextRow(PrimaryKey primaryKey) throws IOException
+    {
+        tokenWriter.add(primaryKey.token().getLongValue());
+        sortedTermsWriter.add(primaryKey::asComparableBytes);
+    }
+
+    @Override
+    public void complete() throws IOException
+    {
+        try
+        {
+            indexDescriptor.createComponentOnDisk(IndexComponent.GROUP_COMPLETION_MARKER);
+        }
+        finally
+        {
+            IOUtils.close(tokenWriter, sortedTermsWriter, metadataWriter);
+        }
+    }
+
+    @Override
+    public void abort()
+    {
+        logger.debug(indexDescriptor.logMessage("Aborting per-SSTable index component writer for {}..."), indexDescriptor.sstableDescriptor);
+        indexDescriptor.deletePerSSTableIndexComponents();
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/SSTableIndexWriter.java b/src/java/org/apache/cassandra/index/sai/disk/v1/SSTableIndexWriter.java
new file mode 100644
index 0000000000..2334b9cf42
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/SSTableIndexWriter.java
@@ -0,0 +1,336 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk.v1;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.function.BooleanSupplier;
+import javax.annotation.concurrent.NotThreadSafe;
+
+import com.google.common.base.Stopwatch;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.cassandra.db.marshal.AbstractType;
+import org.apache.cassandra.db.rows.Row;
+import org.apache.cassandra.index.sai.IndexContext;
+import org.apache.cassandra.index.sai.analyzer.AbstractAnalyzer;
+import org.apache.cassandra.index.sai.disk.PerColumnIndexWriter;
+import org.apache.cassandra.index.sai.disk.format.IndexComponent;
+import org.apache.cassandra.index.sai.disk.format.IndexDescriptor;
+import org.apache.cassandra.index.sai.disk.v1.segment.SegmentBuilder;
+import org.apache.cassandra.index.sai.disk.v1.segment.SegmentMetadata;
+import org.apache.cassandra.index.sai.utils.NamedMemoryLimiter;
+import org.apache.cassandra.index.sai.utils.PrimaryKey;
+import org.apache.cassandra.index.sai.utils.TypeUtil;
+import org.apache.cassandra.utils.Clock;
+import org.apache.cassandra.utils.FBUtilities;
+import org.apache.cassandra.utils.NoSpamLogger;
+
+/**
+ * Column index writer that accumulates (on-heap) indexed data from a compacted SSTable as it's being flushed to disk.
+ */
+@NotThreadSafe
+public class SSTableIndexWriter implements PerColumnIndexWriter
+{
+    private static final Logger logger = LoggerFactory.getLogger(SSTableIndexWriter.class);
+    private static final NoSpamLogger noSpamLogger = NoSpamLogger.getLogger(logger, 1, TimeUnit.MINUTES);
+
+    public static final int MAX_STRING_TERM_SIZE = Integer.getInteger("cassandra.sai.max_string_term_size_kb", 1) * 1024;
+    public static final int MAX_FROZEN_TERM_SIZE = Integer.getInteger("cassandra.sai.max_frozen_term_size_kb", 5) * 1024;
+    public static final String TERM_OVERSIZE_MESSAGE = "Can't add term of column {} to index for key: {}, term size {} " +
+                                                       "max allowed size {}, use analyzed = true (if not yet set) for that column.";
+
+    private final IndexDescriptor indexDescriptor;
+    private final IndexContext indexContext;
+    private final long nowInSec = FBUtilities.nowInSeconds();
+    private final AbstractAnalyzer analyzer;
+    private final NamedMemoryLimiter limiter;
+    private final int maxTermSize;
+    private final BooleanSupplier isIndexValid;
+    private final List<SegmentMetadata> segments = new ArrayList<>();
+
+    private boolean aborted = false;
+    private SegmentBuilder currentBuilder;
+
+    public SSTableIndexWriter(IndexDescriptor indexDescriptor, IndexContext indexContext, NamedMemoryLimiter limiter, BooleanSupplier isIndexValid)
+    {
+        this.indexDescriptor = indexDescriptor;
+        this.indexContext = indexContext;
+        this.analyzer = indexContext.getIndexAnalyzerFactory().create();
+        this.limiter = limiter;
+        this.isIndexValid = isIndexValid;
+        this.maxTermSize = indexContext.isFrozen() ? MAX_FROZEN_TERM_SIZE : MAX_STRING_TERM_SIZE;
+    }
+
+    @Override
+    public void addRow(PrimaryKey key, Row row, long sstableRowId) throws IOException
+    {
+        if (maybeAbort())
+            return;
+
+        if (indexContext.isNonFrozenCollection())
+        {
+            Iterator<ByteBuffer> valueIterator = indexContext.getValuesOf(row, nowInSec);
+            if (valueIterator != null)
+            {
+                while (valueIterator.hasNext())
+                {
+                    ByteBuffer value = valueIterator.next();
+                    addTerm(TypeUtil.asIndexBytes(value.duplicate(), indexContext.getValidator()), key, sstableRowId, indexContext.getValidator());
+                }
+            }
+        }
+        else
+        {
+            ByteBuffer value = indexContext.getValueOf(key.partitionKey(), row, nowInSec);
+            if (value != null)
+                addTerm(TypeUtil.asIndexBytes(value.duplicate(), indexContext.getValidator()), key, sstableRowId, indexContext.getValidator());
+        }
+    }
+
+    @Override
+    public void complete(Stopwatch stopwatch) throws IOException
+    {
+        if (maybeAbort())
+            return;
+
+        long start = stopwatch.elapsed(TimeUnit.MILLISECONDS);
+        long elapsed;
+
+        boolean emptySegment = currentBuilder == null || currentBuilder.isEmpty();
+        logger.debug(indexContext.logMessage("Completing index flush with {}buffered data..."), emptySegment ? "no " : "");
+
+        try
+        {
+            // parts are present but there is something still in memory, let's flush that inline
+            if (!emptySegment)
+            {
+                flushSegment();
+                elapsed = stopwatch.elapsed(TimeUnit.MILLISECONDS);
+                logger.debug(indexContext.logMessage("Completed flush of final segment for SSTable {}. Duration: {} ms. Total elapsed: {} ms"),
+                             indexDescriptor.sstableDescriptor,
+                             elapsed - start,
+                             elapsed);
+            }
+
+            // Even an empty segment may carry some fixed memory, so remove it:
+            if (currentBuilder != null)
+            {
+                long bytesAllocated = currentBuilder.totalBytesAllocated();
+                long globalBytesUsed = currentBuilder.release(indexContext);
+                logger.debug(indexContext.logMessage("Flushing final segment for SSTable {} released {}. Global segment memory usage now at {}."),
+                             indexDescriptor.sstableDescriptor, FBUtilities.prettyPrintMemory(bytesAllocated), FBUtilities.prettyPrintMemory(globalBytesUsed));
+            }
+
+            writeSegmentsMetadata();
+            indexDescriptor.createComponentOnDisk(IndexComponent.COLUMN_COMPLETION_MARKER, indexContext);
+        }
+        finally
+        {
+            if (indexContext.getIndexMetrics() != null)
+            {
+                indexContext.getIndexMetrics().segmentsPerCompaction.update(segments.size());
+                segments.clear();
+                indexContext.getIndexMetrics().compactionCount.inc();
+            }
+        }
+    }
+
+    @Override
+    public void abort(Throwable cause)
+    {
+        aborted = true;
+
+        logger.warn(indexContext.logMessage("Aborting SSTable index flush for {}..."), indexDescriptor.sstableDescriptor, cause);
+
+        // It's possible for the current builder to be unassigned after we flush a final segment.
+        if (currentBuilder != null)
+        {
+            // If an exception is thrown out of any writer operation prior to successful segment
+            // flush, we will end up here, and we need to free up builder memory tracked by the limiter:
+            long allocated = currentBuilder.totalBytesAllocated();
+            long globalBytesUsed = currentBuilder.release(indexContext);
+            logger.debug(indexContext.logMessage("Aborting index writer for SSTable {} released {}. Global segment memory usage now at {}."),
+                         indexDescriptor.sstableDescriptor, FBUtilities.prettyPrintMemory(allocated), FBUtilities.prettyPrintMemory(globalBytesUsed));
+        }
+
+        indexDescriptor.deleteColumnIndex(indexContext);
+    }
+
+    /**
+     * abort current write if index is dropped
+     *
+     * @return true if current write is aborted.
+     */
+    private boolean maybeAbort()
+    {
+        if (aborted)
+            return true;
+
+        if (isIndexValid.getAsBoolean())
+            return false;
+
+        abort(new RuntimeException(String.format("index %s is dropped", indexContext.getIndexName())));
+        return true;
+    }
+
+    private void addTerm(ByteBuffer term, PrimaryKey key, long sstableRowId, AbstractType<?> type) throws IOException
+    {
+        if (term.remaining() >= maxTermSize)
+        {
+            noSpamLogger.warn(indexContext.logMessage(TERM_OVERSIZE_MESSAGE),
+                              indexContext.getColumnName(),
+                              indexContext.keyValidator().getString(key.partitionKey().getKey()),
+                              FBUtilities.prettyPrintMemory(term.remaining()),
+                              FBUtilities.prettyPrintMemory(maxTermSize));
+            return;
+        }
+
+        if (currentBuilder == null)
+        {
+            currentBuilder = newSegmentBuilder();
+        }
+        else if (shouldFlush(sstableRowId))
+        {
+            flushSegment();
+            currentBuilder = newSegmentBuilder();
+        }
+
+        if (term.remaining() == 0) return;
+
+        if (!TypeUtil.isLiteral(type))
+        {
+            limiter.increment(currentBuilder.add(term, key, sstableRowId));
+        }
+        else
+        {
+            analyzer.reset(term);
+            try
+            {
+                while (analyzer.hasNext())
+                {
+                    ByteBuffer tokenTerm = analyzer.next();
+                    limiter.increment(currentBuilder.add(tokenTerm, key, sstableRowId));
+                }
+            }
+            finally
+            {
+                analyzer.end();
+            }
+        }
+    }
+
+    private boolean shouldFlush(long sstableRowId)
+    {
+        // If we've hit the minimum flush size and, we've breached the global limit, flush a new segment:
+        boolean reachMemoryLimit = limiter.usageExceedsLimit() && currentBuilder.hasReachedMinimumFlushSize();
+
+        if (reachMemoryLimit)
+        {
+            logger.debug(indexContext.logMessage("Global limit of {} and minimum flush size of {} exceeded. " +
+                                            "Current builder usage is {} for {} cells. Global Usage is {}. Flushing..."),
+                         FBUtilities.prettyPrintMemory(limiter.limitBytes()),
+                         FBUtilities.prettyPrintMemory(currentBuilder.getMinimumFlushBytes()),
+                         FBUtilities.prettyPrintMemory(currentBuilder.totalBytesAllocated()),
+                         currentBuilder.getRowCount(),
+                         FBUtilities.prettyPrintMemory(limiter.currentBytesUsed()));
+        }
+
+        return reachMemoryLimit || currentBuilder.exceedsSegmentLimit(sstableRowId);
+    }
+
+    private void flushSegment() throws IOException
+    {
+        long start = Clock.Global.nanoTime();
+
+        try
+        {
+            long bytesAllocated = currentBuilder.totalBytesAllocated();
+
+            SegmentMetadata segmentMetadata = currentBuilder.flush(indexDescriptor, indexContext);
+
+            long flushMillis = Math.max(1, TimeUnit.NANOSECONDS.toMillis(Clock.Global.nanoTime() - start));
+
+            if (segmentMetadata != null)
+            {
+                segments.add(segmentMetadata);
+
+                double rowCount = segmentMetadata.numRows;
+                if (indexContext.getIndexMetrics() != null)
+                    indexContext.getIndexMetrics().compactionSegmentCellsPerSecond.update((long)(rowCount / flushMillis * 1000.0));
+
+                double segmentBytes = segmentMetadata.componentMetadatas.indexSize();
+                if (indexContext.getIndexMetrics() != null)
+                    indexContext.getIndexMetrics().compactionSegmentBytesPerSecond.update((long)(segmentBytes / flushMillis * 1000.0));
+
+                logger.debug(indexContext.logMessage("Flushed segment with {} cells for a total of {} in {} ms."),
+                             (long) rowCount, FBUtilities.prettyPrintMemory((long) segmentBytes), flushMillis);
+            }
+
+            // Builder memory is released against the limiter at the conclusion of a successful
+            // flush. Note that any failure that occurs before this (even in term addition) will
+            // actuate this column writer's abort logic from the parent SSTable-level writer, and
+            // that abort logic will release the current builder's memory against the limiter.
+            long globalBytesUsed = currentBuilder.release(indexContext);
+            currentBuilder = null;
+            logger.debug(indexContext.logMessage("Flushing index segment for SSTable {} released {}. Global segment memory usage now at {}."),
+                         indexDescriptor.sstableDescriptor, FBUtilities.prettyPrintMemory(bytesAllocated), FBUtilities.prettyPrintMemory(globalBytesUsed));
+
+        }
+        catch (Throwable t)
+        {
+            logger.error(indexContext.logMessage("Failed to build index for SSTable {}."), indexDescriptor.sstableDescriptor, t);
+            indexDescriptor.deleteColumnIndex(indexContext);
+            indexContext.getIndexMetrics().segmentFlushErrors.inc();
+            throw t;
+        }
+    }
+
+    private void writeSegmentsMetadata() throws IOException
+    {
+        if (segments.isEmpty())
+            return;
+
+        try (MetadataWriter writer = new MetadataWriter(indexDescriptor.openPerIndexOutput(IndexComponent.META, indexContext)))
+        {
+            SegmentMetadata.write(writer, segments);
+        }
+        catch (IOException e)
+        {
+            abort(e);
+            throw e;
+        }
+    }
+
+    private SegmentBuilder newSegmentBuilder()
+    {
+        SegmentBuilder builder = new SegmentBuilder.RAMStringSegmentBuilder(indexContext.getValidator(), limiter);
+
+        long globalBytesUsed = limiter.increment(builder.totalBytesAllocated());
+        logger.debug(indexContext.logMessage("Created new segment builder while flushing SSTable {}. Global segment memory usage now at {}."),
+                     indexDescriptor.sstableDescriptor,
+                     FBUtilities.prettyPrintMemory(globalBytesUsed));
+
+        return builder;
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/V1OnDiskFormat.java b/src/java/org/apache/cassandra/index/sai/disk/v1/V1OnDiskFormat.java
new file mode 100644
index 0000000000..c55d4b07ef
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/V1OnDiskFormat.java
@@ -0,0 +1,247 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.index.sai.disk.v1;
+
+import java.io.IOException;
+import java.util.EnumSet;
+import java.util.Set;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.codahale.metrics.Gauge;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.db.compaction.OperationType;
+import org.apache.cassandra.db.lifecycle.LifecycleNewTracker;
+import org.apache.cassandra.index.sai.IndexContext;
+import org.apache.cassandra.index.sai.SSTableContext;
+import org.apache.cassandra.index.sai.StorageAttachedIndex;
+import org.apache.cassandra.index.sai.disk.PerColumnIndexWriter;
+import org.apache.cassandra.index.sai.disk.PerSSTableIndexWriter;
+import org.apache.cassandra.index.sai.disk.PrimaryKeyMap;
+import org.apache.cassandra.index.sai.disk.RowMapping;
+import org.apache.cassandra.index.sai.disk.SSTableIndex;
+import org.apache.cassandra.index.sai.disk.format.IndexComponent;
+import org.apache.cassandra.index.sai.disk.format.IndexDescriptor;
+import org.apache.cassandra.index.sai.disk.format.OnDiskFormat;
+import org.apache.cassandra.index.sai.disk.v1.segment.SegmentBuilder;
+import org.apache.cassandra.index.sai.metrics.AbstractMetrics;
+import org.apache.cassandra.index.sai.utils.NamedMemoryLimiter;
+import org.apache.cassandra.io.sstable.format.SSTableReader;
+import org.apache.cassandra.metrics.CassandraMetricsRegistry;
+import org.apache.cassandra.metrics.DefaultNameFactory;
+import org.apache.lucene.store.IndexInput;
+
+import static org.apache.cassandra.utils.FBUtilities.prettyPrintMemory;
+
+public class V1OnDiskFormat implements OnDiskFormat
+{
+    private static final Logger logger = LoggerFactory.getLogger(V1OnDiskFormat.class);
+
+    private static final Set<IndexComponent> PER_SSTABLE_COMPONENTS = EnumSet.of(IndexComponent.GROUP_COMPLETION_MARKER,
+                                                                                 IndexComponent.GROUP_META,
+                                                                                 IndexComponent.TOKEN_VALUES,
+                                                                                 IndexComponent.PRIMARY_KEY_TRIE,
+                                                                                 IndexComponent.PRIMARY_KEY_BLOCKS,
+                                                                                 IndexComponent.PRIMARY_KEY_BLOCK_OFFSETS);
+    private static final Set<IndexComponent> LITERAL_COMPONENTS = EnumSet.of(IndexComponent.COLUMN_COMPLETION_MARKER,
+                                                                             IndexComponent.META,
+                                                                             IndexComponent.TERMS_DATA,
+                                                                             IndexComponent.POSTING_LISTS);
+
+    /**
+     * Global limit on heap consumed by all index segment building that occurs outside the context of Memtable flush.
+     *
+     * Note that to avoid flushing small index segments, a segment is only flushed when
+     * both the global size of all building segments has breached the limit and the size of the
+     * segment in question reaches (segment_write_buffer_space_mb / # currently building column indexes).
+     *
+     * ex. If there is only one column index building, it can buffer up to segment_write_buffer_space_mb.
+     *
+     * ex. If there is one column index building per table across 8 compactors, each index will be
+     *     eligible to flush once it reaches (segment_write_buffer_space_mb / 8) MBs.
+     */
+    public static final long SEGMENT_BUILD_MEMORY_LIMIT = DatabaseDescriptor.getSAISegmentWriteBufferSpace().toBytes();
+
+    public static final NamedMemoryLimiter SEGMENT_BUILD_MEMORY_LIMITER = new NamedMemoryLimiter(SEGMENT_BUILD_MEMORY_LIMIT,
+                                                                                                 "Storage Attached Index Segment Builder");
+
+    static
+    {
+        CassandraMetricsRegistry.MetricName bufferSpaceUsed = DefaultNameFactory.createMetricName(AbstractMetrics.TYPE, "SegmentBufferSpaceUsedBytes", null);
+        CassandraMetricsRegistry.Metrics.register(bufferSpaceUsed, (Gauge<Long>) SEGMENT_BUILD_MEMORY_LIMITER::currentBytesUsed);
+
+        CassandraMetricsRegistry.MetricName bufferSpaceLimit = DefaultNameFactory.createMetricName(AbstractMetrics.TYPE, "SegmentBufferSpaceLimitBytes", null);
+        CassandraMetricsRegistry.Metrics.register(bufferSpaceLimit, (Gauge<Long>) () -> SEGMENT_BUILD_MEMORY_LIMIT);
+
+        CassandraMetricsRegistry.MetricName buildsInProgress = DefaultNameFactory.createMetricName(AbstractMetrics.TYPE, "ColumnIndexBuildsInProgress", null);
+        CassandraMetricsRegistry.Metrics.register(buildsInProgress, (Gauge<Integer>) SegmentBuilder::getActiveBuilderCount);
+    }
+
+    public static final V1OnDiskFormat instance = new V1OnDiskFormat();
+
+    protected V1OnDiskFormat()
+    {}
+
+    @Override
+    public PrimaryKeyMap.Factory newPrimaryKeyMapFactory(IndexDescriptor indexDescriptor, SSTableReader sstable)
+    {
+        return new RowAwarePrimaryKeyMap.RowAwarePrimaryKeyMapFactory(indexDescriptor, sstable);
+    }
+
+    @Override
+    public SSTableIndex newSSTableIndex(SSTableContext sstableContext, IndexContext indexContext)
+    {
+        return new V1SSTableIndex(sstableContext, indexContext);
+    }
+
+    @Override
+    public PerSSTableIndexWriter newPerSSTableIndexWriter(IndexDescriptor indexDescriptor) throws IOException
+    {
+        return new SSTableComponentsWriter(indexDescriptor);
+    }
+
+    @Override
+    public PerColumnIndexWriter newPerColumnIndexWriter(StorageAttachedIndex index,
+                                                        IndexDescriptor indexDescriptor,
+                                                        LifecycleNewTracker tracker,
+                                                        RowMapping rowMapping)
+    {
+        // If we're not flushing, or we haven't yet started the initialization build, flush from SSTable contents.
+        if (tracker.opType() != OperationType.FLUSH || !index.isInitBuildStarted())
+        {
+            NamedMemoryLimiter limiter = SEGMENT_BUILD_MEMORY_LIMITER;
+            logger.info(index.getIndexContext().logMessage("Starting a compaction index build. Global segment memory usage: {}"),
+                        prettyPrintMemory(limiter.currentBytesUsed()));
+
+            return new SSTableIndexWriter(indexDescriptor, index.getIndexContext(), limiter, index.isIndexValid());
+        }
+
+        return new MemtableIndexWriter(index.getIndexContext().getMemtableIndexManager().getPendingMemtableIndex(tracker),
+                                       indexDescriptor,
+                                       index.getIndexContext(),
+                                       rowMapping);
+    }
+
+    @Override
+    public boolean isPerSSTableIndexBuildComplete(IndexDescriptor indexDescriptor)
+    {
+        return indexDescriptor.hasComponent(IndexComponent.GROUP_COMPLETION_MARKER);
+    }
+
+    @Override
+    public boolean isPerColumnIndexBuildComplete(IndexDescriptor indexDescriptor, IndexContext indexContext)
+    {
+        return indexDescriptor.hasComponent(IndexComponent.GROUP_COMPLETION_MARKER) &&
+               indexDescriptor.hasComponent(IndexComponent.COLUMN_COMPLETION_MARKER, indexContext);
+    }
+
+    @Override
+    public boolean validatePerSSTableIndexComponents(IndexDescriptor indexDescriptor, boolean checksum)
+    {
+        for (IndexComponent indexComponent : perSSTableIndexComponents())
+        {
+            if (isNotBuildCompletionMarker(indexComponent))
+            {
+                try (IndexInput input = indexDescriptor.openPerSSTableInput(indexComponent))
+                {
+                    if (checksum)
+                        SAICodecUtils.validateChecksum(input);
+                    else
+                        SAICodecUtils.validate(input);
+                }
+                catch (Throwable e)
+                {
+                    if (logger.isDebugEnabled())
+                    {
+                        logger.debug(indexDescriptor.logMessage("{} failed for index component {} on SSTable {}"),
+                                     (checksum ? "Checksum validation" : "Validation"),
+                                     indexComponent,
+                                     indexDescriptor.sstableDescriptor);
+                    }
+                    return false;
+                }
+            }
+        }
+        return true;
+    }
+
+    @Override
+    public boolean validatePerColumnIndexComponents(IndexDescriptor indexDescriptor, IndexContext indexContext, boolean checksum)
+    {
+        for (IndexComponent indexComponent : perColumnIndexComponents(indexContext))
+        {
+            if (isNotBuildCompletionMarker(indexComponent))
+            {
+                try (IndexInput input = indexDescriptor.openPerIndexInput(indexComponent, indexContext))
+                {
+                    if (checksum)
+                        SAICodecUtils.validateChecksum(input);
+                    else
+                        SAICodecUtils.validate(input);
+                }
+                catch (Throwable e)
+                {
+                    if (logger.isDebugEnabled())
+                    {
+                        logger.debug(indexDescriptor.logMessage("{} failed for index component {} on SSTable {}"),
+                                     (checksum ? "Checksum validation" : "Validation"),
+                                     indexComponent,
+                                     indexDescriptor.sstableDescriptor);
+                    }
+                    return false;
+                }
+            }
+        }
+        return true;
+    }
+
+    @Override
+    public Set<IndexComponent> perSSTableIndexComponents()
+    {
+        return PER_SSTABLE_COMPONENTS;
+    }
+
+    @Override
+    public Set<IndexComponent> perColumnIndexComponents(IndexContext indexContext)
+    {
+        return LITERAL_COMPONENTS;
+    }
+
+    @Override
+    public int openFilesPerSSTableIndex()
+    {
+        // For the V1 format there are always 4 open files per SSTable - token values, primary key trie,
+        // primary key blocks, primary key block offsets
+        return 4;
+    }
+
+    @Override
+    public int openFilesPerColumnIndex(IndexContext indexContext)
+    {
+        // For the V1 format there are always 2 open files per index - index (kdtree or terms) + postings
+        return 2;
+    }
+
+    protected boolean isNotBuildCompletionMarker(IndexComponent indexComponent)
+    {
+        return indexComponent != IndexComponent.GROUP_COMPLETION_MARKER &&
+               indexComponent != IndexComponent.COLUMN_COMPLETION_MARKER;
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/V1SSTableIndex.java b/src/java/org/apache/cassandra/index/sai/disk/v1/V1SSTableIndex.java
new file mode 100644
index 0000000000..fd89b302c8
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/V1SSTableIndex.java
@@ -0,0 +1,204 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.index.sai.disk.v1;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.List;
+
+import com.google.common.collect.ImmutableList;
+
+import org.apache.cassandra.db.DecoratedKey;
+import org.apache.cassandra.db.PartitionPosition;
+import org.apache.cassandra.db.virtual.SimpleDataSet;
+import org.apache.cassandra.dht.AbstractBounds;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.index.sai.IndexContext;
+import org.apache.cassandra.index.sai.QueryContext;
+import org.apache.cassandra.index.sai.SSTableContext;
+import org.apache.cassandra.index.sai.disk.SSTableIndex;
+import org.apache.cassandra.index.sai.disk.v1.segment.Segment;
+import org.apache.cassandra.index.sai.disk.v1.segment.SegmentMetadata;
+import org.apache.cassandra.index.sai.iterators.KeyRangeIterator;
+import org.apache.cassandra.index.sai.plan.Expression;
+import org.apache.cassandra.index.sai.utils.TypeUtil;
+import org.apache.cassandra.io.sstable.format.SSTableReader;
+import org.apache.cassandra.io.util.FileUtils;
+import org.apache.cassandra.utils.Throwables;
+
+import static org.apache.cassandra.index.sai.virtual.SegmentsSystemView.CELL_COUNT;
+import static org.apache.cassandra.index.sai.virtual.SegmentsSystemView.COLUMN_NAME;
+import static org.apache.cassandra.index.sai.virtual.SegmentsSystemView.COMPONENT_METADATA;
+import static org.apache.cassandra.index.sai.virtual.SegmentsSystemView.END_TOKEN;
+import static org.apache.cassandra.index.sai.virtual.SegmentsSystemView.MAX_SSTABLE_ROW_ID;
+import static org.apache.cassandra.index.sai.virtual.SegmentsSystemView.MAX_TERM;
+import static org.apache.cassandra.index.sai.virtual.SegmentsSystemView.MIN_SSTABLE_ROW_ID;
+import static org.apache.cassandra.index.sai.virtual.SegmentsSystemView.MIN_TERM;
+import static org.apache.cassandra.index.sai.virtual.SegmentsSystemView.START_TOKEN;
+import static org.apache.cassandra.index.sai.virtual.SegmentsSystemView.TABLE_NAME;
+
+/**
+ * A version specific implementation of the {@link SSTableIndex} where the
+ * index is segmented
+ */
+public class V1SSTableIndex extends SSTableIndex
+{
+    private final ImmutableList<Segment> segments;
+    private final List<SegmentMetadata> metadatas;
+    private final AbstractBounds<PartitionPosition> bounds;
+    private final ByteBuffer minTerm;
+    private final ByteBuffer maxTerm;
+    private final long minSSTableRowId, maxSSTableRowId;
+    private final long numRows;
+
+    private PerColumnIndexFiles indexFiles;
+
+    public V1SSTableIndex(SSTableContext sstableContext, IndexContext indexContext)
+    {
+        super(sstableContext, indexContext);
+
+        try
+        {
+            this.indexFiles = new PerColumnIndexFiles(sstableContext.indexDescriptor, indexContext);
+
+            ImmutableList.Builder<Segment> segmentsBuilder = ImmutableList.builder();
+
+            final MetadataSource source = MetadataSource.loadColumnMetadata(sstableContext.indexDescriptor, indexContext);
+
+            metadatas = SegmentMetadata.load(source, sstableContext.indexDescriptor.primaryKeyFactory);
+
+            for (SegmentMetadata metadata : metadatas)
+            {
+                segmentsBuilder.add(new Segment(indexContext, sstableContext, indexFiles, metadata));
+            }
+
+            segments = segmentsBuilder.build();
+            assert !segments.isEmpty();
+
+            DecoratedKey minKey = metadatas.get(0).minKey.partitionKey();
+            DecoratedKey maxKey = metadatas.get(metadatas.size() - 1).maxKey.partitionKey();
+
+            this.bounds = AbstractBounds.bounds(minKey, true, maxKey, true);
+
+            this.minTerm = metadatas.stream().map(m -> m.minTerm).min(TypeUtil.comparator(indexContext.getValidator())).orElse(null);
+            this.maxTerm = metadatas.stream().map(m -> m.maxTerm).max(TypeUtil.comparator(indexContext.getValidator())).orElse(null);
+
+            this.numRows = metadatas.stream().mapToLong(m -> m.numRows).sum();
+
+            this.minSSTableRowId = metadatas.get(0).minSSTableRowId;
+            this.maxSSTableRowId = metadatas.get(metadatas.size() - 1).maxSSTableRowId;
+        }
+        catch (Throwable t)
+        {
+            FileUtils.closeQuietly(indexFiles);
+            FileUtils.closeQuietly(sstableContext);
+            throw Throwables.unchecked(t);
+        }
+    }
+
+    @Override
+    public long indexFileCacheSize()
+    {
+        return segments.stream().mapToLong(Segment::indexFileCacheSize).sum();
+    }
+
+    @Override
+    public long getRowCount()
+    {
+        return numRows;
+    }
+
+    @Override
+    public long minSSTableRowId()
+    {
+        return minSSTableRowId;
+    }
+
+    @Override
+    public long maxSSTableRowId()
+    {
+        return maxSSTableRowId;
+    }
+
+    @Override
+    public ByteBuffer minTerm()
+    {
+        return minTerm;
+    }
+
+    @Override
+    public ByteBuffer maxTerm()
+    {
+        return maxTerm;
+    }
+
+    @Override
+    public AbstractBounds<PartitionPosition> bounds()
+    {
+        return bounds;
+    }
+
+    @Override
+    public List<KeyRangeIterator> search(Expression expression,
+                                         AbstractBounds<PartitionPosition> keyRange,
+                                         QueryContext context) throws IOException
+    {
+        List<KeyRangeIterator> segmentIterators = new ArrayList<>();
+
+        for (Segment segment : segments)
+        {
+            if (segment.intersects(keyRange))
+            {
+                segmentIterators.add(segment.search(expression, context));
+            }
+        }
+
+        return segmentIterators;
+    }
+
+    @Override
+    public void populateSegmentView(SimpleDataSet dataset)
+    {
+        SSTableReader sstable = getSSTable();
+        Token.TokenFactory tokenFactory = sstable.metadata().partitioner.getTokenFactory();
+
+        for (SegmentMetadata metadata : metadatas)
+        {
+            dataset.row(sstable.metadata().keyspace, indexContext.getIndexName(), sstable.getFilename(), metadata.rowIdOffset)
+                   .column(TABLE_NAME, sstable.descriptor.cfname)
+                   .column(COLUMN_NAME, indexContext.getColumnName())
+                   .column(CELL_COUNT, metadata.numRows)
+                   .column(MIN_SSTABLE_ROW_ID, metadata.minSSTableRowId)
+                   .column(MAX_SSTABLE_ROW_ID, metadata.maxSSTableRowId)
+                   .column(START_TOKEN, tokenFactory.toString(metadata.minKey.token()))
+                   .column(END_TOKEN, tokenFactory.toString(metadata.maxKey.token()))
+                   .column(MIN_TERM, indexContext.getValidator().getSerializer().deserialize(metadata.minTerm).toString())
+                   .column(MAX_TERM, indexContext.getValidator().getSerializer().deserialize(metadata.maxTerm).toString())
+                   .column(COMPONENT_METADATA, metadata.componentMetadatas.asMap());
+        }
+    }
+
+    @Override
+    protected void internalRelease()
+    {
+        FileUtils.closeQuietly(indexFiles);
+        FileUtils.closeQuietly(segments);
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/bitpack/AbstractBlockPackedReader.java b/src/java/org/apache/cassandra/index/sai/disk/v1/bitpack/AbstractBlockPackedReader.java
new file mode 100644
index 0000000000..4d0bb40118
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/bitpack/AbstractBlockPackedReader.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk.v1.bitpack;
+
+import javax.annotation.concurrent.NotThreadSafe;
+
+import org.apache.cassandra.index.sai.disk.io.SeekingRandomAccessInput;
+import org.apache.cassandra.index.sai.disk.v1.DirectReaders;
+import org.apache.cassandra.index.sai.disk.v1.LongArray;
+import org.apache.lucene.store.IndexInput;
+
+@NotThreadSafe
+public abstract class AbstractBlockPackedReader implements LongArray
+{
+    private final int blockShift;
+    private final int blockMask;
+    private final long valueCount;
+    private final byte[] blockBitsPerValue;
+    private final SeekingRandomAccessInput input;
+
+    AbstractBlockPackedReader(IndexInput indexInput, byte[] blockBitsPerValue, int blockShift, int blockMask, long valueCount)
+    {
+        this.blockShift = blockShift;
+        this.blockMask = blockMask;
+        this.valueCount = valueCount;
+        this.input = new SeekingRandomAccessInput(indexInput);
+        this.blockBitsPerValue = blockBitsPerValue;
+    }
+
+    protected abstract long blockOffsetAt(int block);
+
+    @Override
+    public long get(final long valueIndex)
+    {
+        if (valueIndex < 0 || valueIndex >= valueCount)
+        {
+            throw new IndexOutOfBoundsException(String.format("Index should be between [0, %d), but was %d.", valueCount, valueIndex));
+        }
+
+        int blockIndex = (int) (valueIndex >>> blockShift);
+        int inBlockIndex = (int) (valueIndex & blockMask);
+        DirectReaders.Reader subReader = DirectReaders.getReaderForBitsPerValue(blockBitsPerValue[blockIndex]);
+        return delta(blockIndex, inBlockIndex) + subReader.get(input, blockOffsetAt(blockIndex), inBlockIndex);
+    }
+
+    @Override
+    public long length()
+    {
+        return valueCount;
+    }
+
+    abstract long delta(int block, int idx);
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/bitpack/AbstractBlockPackedWriter.java b/src/java/org/apache/cassandra/index/sai/disk/v1/bitpack/AbstractBlockPackedWriter.java
new file mode 100644
index 0000000000..ead4f5986f
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/bitpack/AbstractBlockPackedWriter.java
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk.v1.bitpack;
+
+import java.io.IOException;
+
+import org.apache.cassandra.index.sai.disk.io.RAMIndexOutput;
+import org.apache.lucene.store.IndexOutput;
+import org.apache.lucene.util.packed.DirectWriter;
+
+import static org.apache.cassandra.index.sai.disk.v1.SAICodecUtils.checkBlockSize;
+
+/**
+ * Modified copy of {@code org.apache.lucene.util.packed.AbstractBlockPackedWriter} to use {@link DirectWriter} for
+ * optimised reads that doesn't require seeking through the whole file to open a thread-exclusive reader.
+ */
+public abstract class AbstractBlockPackedWriter
+{
+    static final int MIN_BLOCK_SIZE = 64;
+    static final int MAX_BLOCK_SIZE = 1 << (30 - 3);
+
+    protected final IndexOutput indexOutput;
+    protected final long[] blockValues;
+    // This collects metadata specific to the block packed writer being used during the
+    // writing of the block packed data. This cached metadata is then written to the end
+    // of the data file when the block packed writer is finished.
+    protected final RAMIndexOutput blockMetaWriter;
+
+    protected int blockIndex;
+    protected boolean finished;
+
+    AbstractBlockPackedWriter(IndexOutput indexOutput, int blockSize)
+    {
+        checkBlockSize(blockSize, MIN_BLOCK_SIZE, MAX_BLOCK_SIZE);
+        this.indexOutput = indexOutput;
+        this.blockMetaWriter = new RAMIndexOutput("BlockPackedMeta");
+        blockValues = new long[blockSize];
+    }
+
+
+    /**
+     * Append a new long.
+     */
+    public void add(long l) throws IOException
+    {
+        checkNotFinished();
+        if (blockIndex == blockValues.length)
+        {
+            flush();
+        }
+        blockValues[blockIndex++] = l;
+    }
+
+    /**
+     * Flush all buffered data to disk. This instance is not usable anymore
+     * after this method has been called.
+     *
+     * @return a file offset to the block metadata
+     */
+    public long finish() throws IOException
+    {
+        checkNotFinished();
+        if (blockIndex > 0)
+        {
+            flush();
+        }
+        final long fp = indexOutput.getFilePointer();
+        blockMetaWriter.writeTo(indexOutput);
+        finished = true;
+        return fp;
+    }
+
+    protected abstract void flushBlock() throws IOException;
+
+    void writeValues(int numValues, int bitsPerValue) throws IOException
+    {
+        final DirectWriter writer = DirectWriter.getInstance(indexOutput, numValues, bitsPerValue);
+        for (int i = 0; i < numValues; ++i)
+        {
+            writer.add(blockValues[i]);
+        }
+        writer.finish();
+    }
+
+    void writeVLong(IndexOutput out, long i) throws IOException
+    {
+        int k = 0;
+        while ((i & ~0x7FL) != 0L && k++ < 8)
+        {
+            out.writeByte((byte) ((i & 0x7FL) | 0x80L));
+            i >>>= 7;
+        }
+        out.writeByte((byte) i);
+    }
+
+    private void flush() throws IOException
+    {
+        flushBlock();
+        blockIndex = 0;
+    }
+
+    private void checkNotFinished()
+    {
+        if (finished)
+        {
+            throw new IllegalStateException(String.format("[%s] Writer already finished!", indexOutput.getName()));
+        }
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/bitpack/BlockPackedReader.java b/src/java/org/apache/cassandra/index/sai/disk/v1/bitpack/BlockPackedReader.java
new file mode 100644
index 0000000000..fa42b25fb8
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/bitpack/BlockPackedReader.java
@@ -0,0 +1,128 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk.v1.bitpack;
+
+import java.io.IOException;
+
+import org.apache.cassandra.index.sai.disk.io.IndexFileUtils;
+import org.apache.cassandra.index.sai.disk.io.IndexInputReader;
+import org.apache.cassandra.index.sai.disk.v1.DirectReaders;
+import org.apache.cassandra.index.sai.disk.v1.LongArray;
+import org.apache.cassandra.index.sai.disk.v1.SAICodecUtils;
+import org.apache.cassandra.io.util.FileHandle;
+import org.apache.cassandra.io.util.RandomAccessReader;
+import org.apache.lucene.index.CorruptIndexException;
+import org.apache.lucene.store.IndexInput;
+
+import static org.apache.cassandra.index.sai.disk.v1.SAICodecUtils.checkBlockSize;
+import static org.apache.cassandra.index.sai.disk.v1.SAICodecUtils.numBlocks;
+import static org.apache.cassandra.index.sai.disk.v1.SAICodecUtils.readVLong;
+import static org.apache.lucene.util.BitUtil.zigZagDecode;
+
+/**
+ * Provides non-blocking, random access to a stream written with {@link BlockPackedWriter}.
+ */
+public class BlockPackedReader implements LongArray.Factory
+{
+    private final FileHandle file;
+    private final int blockShift;
+    private final int blockMask;
+    private final long valueCount;
+    private final byte[] blockBitsPerValue;
+    private final long[] blockOffsets;
+    private final long[] minValues;
+
+    public BlockPackedReader(FileHandle file, NumericValuesMeta meta) throws IOException
+    {
+        this.file = file;
+
+        this.valueCount = meta.valueCount;
+
+        blockShift = checkBlockSize(meta.blockSize, AbstractBlockPackedWriter.MIN_BLOCK_SIZE, AbstractBlockPackedWriter.MAX_BLOCK_SIZE);
+        blockMask = meta.blockSize - 1;
+        int numBlocks = numBlocks(valueCount, meta.blockSize);
+        blockBitsPerValue = new byte[numBlocks];
+        blockOffsets = new long[numBlocks];
+        minValues = new long[numBlocks];
+
+        try (RandomAccessReader reader = this.file.createReader();
+             IndexInputReader in = IndexInputReader.create(reader))
+        {
+            SAICodecUtils.validate(in);
+            in.seek(meta.blockMetaOffset);
+
+            for (int i = 0; i < numBlocks; ++i)
+            {
+                final int token = in.readByte() & 0xFF;
+                final int bitsPerValue = token >>> BlockPackedWriter.BPV_SHIFT;
+                if (!DirectReaders.SUPPORTED_BITS_PER_VALUE.contains(bitsPerValue))
+                {
+                    throw new CorruptIndexException(String.format("Block %d is corrupted. Bits per value is %d. Supported values are %s.",
+                                                                  i, bitsPerValue, DirectReaders.SUPPORTED_BITS_PER_VALUE_STRING), in);
+                }
+                if ((token & BlockPackedWriter.MIN_VALUE_EQUALS_0) == 0)
+                {
+                    long val = zigZagDecode(1L + readVLong(in));
+                    minValues[i] = val;
+                }
+                else
+                {
+                    minValues[i] = 0L;
+                }
+
+                blockBitsPerValue[i] = (byte) bitsPerValue;
+
+                if (bitsPerValue > 0)
+                {
+                    blockOffsets[i] = in.readVLong();
+                }
+                else
+                {
+                    blockOffsets[i] = -1;
+                }
+            }
+        }
+    }
+
+    @Override
+    @SuppressWarnings({"resource", "RedundantSuppression"})
+    public LongArray open()
+    {
+        IndexInput indexInput = IndexFileUtils.instance.openInput(file);
+        return new AbstractBlockPackedReader(indexInput, blockBitsPerValue, blockShift, blockMask, valueCount)
+        {
+            @Override
+            protected long blockOffsetAt(int block)
+            {
+                return blockOffsets[block];
+            }
+
+            @Override
+            long delta(int block, int idx)
+            {
+                return minValues[block];
+            }
+
+            @Override
+            public void close() throws IOException
+            {
+                indexInput.close();
+            }
+        };
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/bitpack/BlockPackedWriter.java b/src/java/org/apache/cassandra/index/sai/disk/v1/bitpack/BlockPackedWriter.java
new file mode 100644
index 0000000000..9dcc29c6ab
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/bitpack/BlockPackedWriter.java
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk.v1.bitpack;
+
+import java.io.IOException;
+
+import org.apache.lucene.store.IndexOutput;
+import org.apache.lucene.util.packed.DirectWriter;
+
+import static org.apache.lucene.util.BitUtil.zigZagEncode;
+
+/**
+ * A writer for large sequences of longs.
+ *
+ * Modified copy of {@link org.apache.lucene.util.packed.BlockPackedWriter} to use {@link DirectWriter}
+ * for optimised reads that doesn't require seeking through the whole file to open a thread-exclusive reader.
+ */
+public class BlockPackedWriter extends AbstractBlockPackedWriter
+{
+    static final int BPV_SHIFT = 1;
+    static final int MIN_VALUE_EQUALS_0 = 1;
+
+    public BlockPackedWriter(IndexOutput out, int blockSize)
+    {
+        super(out, blockSize);
+    }
+
+    @Override
+    protected void flushBlock() throws IOException
+    {
+        long min = Long.MAX_VALUE, max = Long.MIN_VALUE;
+        for (int i = 0; i < blockIndex; ++i)
+        {
+            min = Math.min(blockValues[i], min);
+            max = Math.max(blockValues[i], max);
+        }
+
+        long delta = max - min;
+        int bitsRequired = delta == 0 ? 0 : DirectWriter.unsignedBitsRequired(delta);
+
+        int shiftedBitsRequired = (bitsRequired << BPV_SHIFT) | (min == 0 ? MIN_VALUE_EQUALS_0 : 0);
+        blockMetaWriter.writeByte((byte) shiftedBitsRequired);
+
+        if (min != 0)
+        {
+            writeVLong(blockMetaWriter, zigZagEncode(min) - 1);
+        }
+
+        if (bitsRequired > 0)
+        {
+            if (min != 0)
+            {
+                for (int i = 0; i < blockIndex; ++i)
+                {
+                    blockValues[i] -= min;
+                }
+            }
+            blockMetaWriter.writeVLong(indexOutput.getFilePointer());
+            writeValues(blockIndex, bitsRequired);
+        }
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/bitpack/MonotonicBlockPackedReader.java b/src/java/org/apache/cassandra/index/sai/disk/v1/bitpack/MonotonicBlockPackedReader.java
new file mode 100644
index 0000000000..872a44e02f
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/bitpack/MonotonicBlockPackedReader.java
@@ -0,0 +1,120 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk.v1.bitpack;
+
+import java.io.IOException;
+
+import org.apache.cassandra.index.sai.disk.io.IndexFileUtils;
+import org.apache.cassandra.index.sai.disk.io.IndexInputReader;
+import org.apache.cassandra.index.sai.disk.v1.DirectReaders;
+import org.apache.cassandra.index.sai.disk.v1.LongArray;
+import org.apache.cassandra.index.sai.disk.v1.SAICodecUtils;
+import org.apache.cassandra.io.util.FileHandle;
+import org.apache.cassandra.io.util.RandomAccessReader;
+import org.apache.lucene.index.CorruptIndexException;
+import org.apache.lucene.store.IndexInput;
+import org.apache.lucene.util.packed.PackedInts;
+import org.apache.lucene.util.packed.PackedLongValues;
+
+import static org.apache.cassandra.index.sai.disk.v1.SAICodecUtils.checkBlockSize;
+import static org.apache.cassandra.index.sai.disk.v1.SAICodecUtils.numBlocks;
+
+/**
+ * Provides non-blocking, random access to a stream written with {@link MonotonicBlockPackedWriter}.
+ */
+public class MonotonicBlockPackedReader implements LongArray.Factory
+{
+    private final FileHandle file;
+    private final int blockShift;
+    private final int blockMask;
+    private final long valueCount;
+    private final byte[] blockBitsPerValue;
+    private final PackedLongValues blockOffsets;
+    private final PackedLongValues minValues;
+    private final float[] averages;
+
+    public MonotonicBlockPackedReader(FileHandle file, NumericValuesMeta meta) throws IOException
+    {
+        this.valueCount = meta.valueCount;
+        blockShift = checkBlockSize(meta.blockSize, AbstractBlockPackedWriter.MIN_BLOCK_SIZE, AbstractBlockPackedWriter.MAX_BLOCK_SIZE);
+        blockMask = meta.blockSize - 1;
+        int numBlocks = numBlocks(valueCount, meta.blockSize);
+        PackedLongValues.Builder minValuesBuilder = PackedLongValues.monotonicBuilder(PackedInts.COMPACT);
+        PackedLongValues.Builder blockOffsetsBuilder = PackedLongValues.monotonicBuilder(PackedInts.COMPACT);
+        averages = new float[numBlocks];
+        blockBitsPerValue = new byte[numBlocks];
+        this.file = file;
+
+        try (RandomAccessReader reader = this.file.createReader();
+             IndexInputReader in = IndexInputReader.create(reader))
+        {
+            SAICodecUtils.validate(in);
+
+            in.seek(meta.blockMetaOffset);
+            for (int i = 0; i < numBlocks; ++i)
+            {
+                minValuesBuilder.add(in.readZLong());
+                averages[i] = Float.intBitsToFloat(in.readInt());
+                final int bitsPerValue = in.readVInt();
+                if (!DirectReaders.SUPPORTED_BITS_PER_VALUE.contains(bitsPerValue))
+                {
+                    throw new CorruptIndexException(String.format("Block %d is corrupted. Bits per value is %d. Supported values are %s.",
+                                                                  i, bitsPerValue, DirectReaders.SUPPORTED_BITS_PER_VALUE_STRING), in);
+                }
+                blockBitsPerValue[i] = (byte) bitsPerValue;
+                // when bitsPerValue is 0, block offset won't be used
+                blockOffsetsBuilder.add(bitsPerValue == 0 ? -1 : in.readVLong());
+            }
+        }
+
+        blockOffsets = blockOffsetsBuilder.build();
+        minValues = minValuesBuilder.build();
+    }
+
+    @Override
+    @SuppressWarnings({"resource", "RedundantSuppression"})
+    public LongArray open()
+    {
+        final IndexInput indexInput = IndexFileUtils.instance.openInput(file);
+        return new AbstractBlockPackedReader(indexInput, blockBitsPerValue, blockShift, blockMask, valueCount)
+        {
+            @Override
+            long delta(int block, int idx)
+            {
+                return expected(minValues.get(block), averages[block], idx);
+            }
+
+            @Override
+            public void close() throws IOException
+            {
+                indexInput.close();
+            }
+
+            @Override
+            protected long blockOffsetAt(int block)
+            {
+                return blockOffsets.get(block);
+            }
+        };
+    }
+
+    public static long expected(long origin, float average, int index)
+    {
+        return origin + (long)(average * index);
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/bitpack/MonotonicBlockPackedWriter.java b/src/java/org/apache/cassandra/index/sai/disk/v1/bitpack/MonotonicBlockPackedWriter.java
new file mode 100644
index 0000000000..5845aabc77
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/bitpack/MonotonicBlockPackedWriter.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk.v1.bitpack;
+
+import java.io.IOException;
+
+import org.apache.lucene.store.IndexOutput;
+import org.apache.lucene.util.packed.DirectWriter;
+
+/**
+ * A writer for large monotonically increasing sequences of positive longs.
+ *
+ * The writer is optimised for monotonic sequences and stores values as a series of deltas
+ * from an expected value. The expected value is calculated from the minimum value in the block and the average
+ * delta for the block. This means that stored values are generally smaller and can be packed
+ * into a smaller number of bits, allowing for larger block sizes.
+ *
+ * Modified copy of {@link org.apache.lucene.util.packed.MonotonicBlockPackedWriter} to use {@link DirectWriter} for
+ * optimised reads that doesn't require seeking through the whole file to open a thread-exclusive reader.
+ */
+public class MonotonicBlockPackedWriter extends AbstractBlockPackedWriter
+{
+    public MonotonicBlockPackedWriter(IndexOutput out, int blockSize)
+    {
+        super(out, blockSize);
+    }
+
+    @Override
+    public void add(long l) throws IOException
+    {
+        assert l >= 0;
+        super.add(l);
+    }
+
+    @Override
+    protected void flushBlock() throws IOException
+    {
+        final float averageDelta = blockIndex == 1 ? 0f : (float) (blockValues[blockIndex - 1] - blockValues[0]) / (blockIndex - 1);
+        long minimumValue = blockValues[0];
+        // adjust minimumValue so that all deltas will be positive
+        for (int index = 1; index < blockIndex; ++index)
+        {
+            long actual = blockValues[index];
+            long expected = MonotonicBlockPackedReader.expected(minimumValue, averageDelta, index);
+            if (expected > actual)
+            {
+                minimumValue -= (expected - actual);
+            }
+        }
+
+        long maxDelta = 0;
+        for (int i = 0; i < blockIndex; ++i)
+        {
+            blockValues[i] = blockValues[i] - MonotonicBlockPackedReader.expected(minimumValue, averageDelta, i);
+            maxDelta = Math.max(maxDelta, blockValues[i]);
+        }
+
+        blockMetaWriter.writeZLong(minimumValue);
+        blockMetaWriter.writeInt(Float.floatToIntBits(averageDelta));
+        if (maxDelta == 0)
+        {
+            blockMetaWriter.writeVInt(0);
+        }
+        else
+        {
+            final int bitsRequired = DirectWriter.bitsRequired(maxDelta);
+            blockMetaWriter.writeVInt(bitsRequired);
+            blockMetaWriter.writeVLong(indexOutput.getFilePointer());
+            writeValues(blockIndex, bitsRequired);
+        }
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/virtual/StorageAttachedIndexTables.java b/src/java/org/apache/cassandra/index/sai/disk/v1/bitpack/NumericValuesMeta.java
similarity index 52%
copy from src/java/org/apache/cassandra/index/sai/virtual/StorageAttachedIndexTables.java
copy to src/java/org/apache/cassandra/index/sai/disk/v1/bitpack/NumericValuesMeta.java
index 0d4f12678f..9d52150f5f 100644
--- a/src/java/org/apache/cassandra/index/sai/virtual/StorageAttachedIndexTables.java
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/bitpack/NumericValuesMeta.java
@@ -15,21 +15,30 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+package org.apache.cassandra.index.sai.disk.v1.bitpack;
 
-package org.apache.cassandra.index.sai.virtual;
+import java.io.IOException;
 
-import java.util.Collection;
-import java.util.Collections;
+import org.apache.lucene.store.IndexInput;
+import org.apache.lucene.store.IndexOutput;
 
-import org.apache.cassandra.db.virtual.VirtualTable;
-
-public class StorageAttachedIndexTables
+public class NumericValuesMeta
 {
-    private StorageAttachedIndexTables()
-    {}
+    public final long valueCount;
+    public final int blockSize;
+    public final long blockMetaOffset;
+
+    public NumericValuesMeta(IndexInput input) throws IOException
+    {
+        valueCount = input.readLong();
+        blockSize = input.readInt();
+        blockMetaOffset = input.readVLong();
+    }
 
-    public static Collection<VirtualTable> getAll(String keyspace)
+    public static void write(IndexOutput out, long valueCount, int blockSize, long blockMetaOffset) throws IOException
     {
-        return Collections.singletonList(new IndexesSystemView(keyspace));
+        out.writeLong(valueCount);
+        out.writeInt(blockSize);
+        out.writeVLong(blockMetaOffset);
     }
 }
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/bitpack/NumericValuesWriter.java b/src/java/org/apache/cassandra/index/sai/disk/v1/bitpack/NumericValuesWriter.java
new file mode 100644
index 0000000000..461c15b7dd
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/bitpack/NumericValuesWriter.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk.v1.bitpack;
+
+import java.io.Closeable;
+import java.io.IOException;
+
+import javax.annotation.concurrent.NotThreadSafe;
+
+import org.apache.cassandra.index.sai.disk.format.IndexComponent;
+import org.apache.cassandra.index.sai.disk.format.IndexDescriptor;
+import org.apache.cassandra.index.sai.disk.v1.MetadataWriter;
+import org.apache.cassandra.index.sai.disk.v1.SAICodecUtils;
+import org.apache.lucene.store.IndexOutput;
+
+@NotThreadSafe
+public class NumericValuesWriter implements Closeable
+{
+    public static final int MONOTONIC_BLOCK_SIZE = 16384;
+    public static final int BLOCK_SIZE = 128;
+
+    private final IndexOutput output;
+    private final AbstractBlockPackedWriter writer;
+    private final MetadataWriter metadataWriter;
+    private final String componentName;
+    private final int blockSize;
+    private long count = 0;
+
+    public NumericValuesWriter(String componentName,
+                               IndexOutput indexOutput,
+                               MetadataWriter metadataWriter,
+                               boolean monotonic) throws IOException
+    {
+        this(componentName, indexOutput, metadataWriter, monotonic, monotonic ? MONOTONIC_BLOCK_SIZE : BLOCK_SIZE);
+    }
+
+    public NumericValuesWriter(IndexDescriptor indexDescriptor,
+                               IndexComponent component,
+                               MetadataWriter metadataWriter,
+                               boolean monotonic,
+                               int blockSize) throws IOException
+    {
+        this(indexDescriptor.componentName(component),
+             indexDescriptor.openPerSSTableOutput(component),
+             metadataWriter,
+             monotonic,
+             blockSize);
+    }
+
+    private NumericValuesWriter(String componentName,
+                                IndexOutput indexOutput,
+                                MetadataWriter metadataWriter,
+                                boolean monotonic, int blockSize) throws IOException
+    {
+        SAICodecUtils.writeHeader(indexOutput);
+        this.writer = monotonic ? new MonotonicBlockPackedWriter(indexOutput, blockSize)
+                                : new BlockPackedWriter(indexOutput, blockSize);
+        this.output = indexOutput;
+        this.componentName = componentName;
+        this.metadataWriter = metadataWriter;
+        this.blockSize = blockSize;
+    }
+
+    @Override
+    public void close() throws IOException
+    {
+        try (IndexOutput o = metadataWriter.builder(componentName))
+        {
+            long fp = writer.finish();
+            SAICodecUtils.writeFooter(output);
+
+            NumericValuesMeta.write(o, count, blockSize, fp);
+        }
+        finally
+        {
+            output.close();
+        }
+    }
+
+    public void add(long value) throws IOException
+    {
+        writer.add(value);
+        count++;
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/postings/MergePostingList.java b/src/java/org/apache/cassandra/index/sai/disk/v1/postings/MergePostingList.java
new file mode 100644
index 0000000000..e20986dd71
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/postings/MergePostingList.java
@@ -0,0 +1,130 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk.v1.postings;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.PriorityQueue;
+import javax.annotation.concurrent.NotThreadSafe;
+
+import org.apache.cassandra.index.sai.postings.PeekablePostingList;
+import org.apache.cassandra.index.sai.postings.PostingList;
+import org.apache.cassandra.io.util.FileUtils;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+/**
+ * Merges multiple {@link PostingList} which individually contain unique items into a single list.
+ * While the individual lists contain unique items, there can be duplicate items between lists so
+ * the class also checks for duplicates and only returns unique items in sorted order.
+ */
+@NotThreadSafe
+public class MergePostingList implements PostingList
+{
+    private final PriorityQueue<PeekablePostingList> postingLists;
+    private final List<PeekablePostingList> temp;
+    private final Closeable onClose;
+    private final long size;
+    private long lastRowId = -1;
+
+    private MergePostingList(PriorityQueue<PeekablePostingList> postingLists, Closeable onClose)
+    {
+        this.temp = new ArrayList<>(postingLists.size());
+        this.onClose = onClose;
+        this.postingLists = postingLists;
+        long totalPostings = 0;
+        for (PostingList postingList : postingLists)
+        {
+            totalPostings += postingList.size();
+        }
+        this.size = totalPostings;
+    }
+
+    public static PostingList merge(PriorityQueue<PeekablePostingList> postings, Closeable onClose)
+    {
+        checkArgument(!postings.isEmpty(), "Cannot merge an empty queue of posting lists");
+        return postings.size() > 1 ? new MergePostingList(postings, onClose) : postings.poll();
+    }
+
+    public static PostingList merge(PriorityQueue<PeekablePostingList> postings)
+    {
+        return merge(postings, () -> FileUtils.close(postings));
+    }
+
+    @SuppressWarnings({"resource", "RedundantSuppression"})
+    @Override
+    public long nextPosting() throws IOException
+    {
+        while (!postingLists.isEmpty())
+        {
+            PeekablePostingList head = postingLists.poll();
+            long next = head.nextPosting();
+
+            if (next == END_OF_STREAM)
+            {
+                // skip current posting list
+                continue;
+            }
+
+            if (next > lastRowId)
+            {
+                lastRowId = next;
+                postingLists.add(head);
+                return next;
+            }
+            else if (next == lastRowId)
+            {
+                postingLists.add(head);
+            }
+        }
+
+        return PostingList.END_OF_STREAM;
+    }
+
+    @SuppressWarnings({"resource", "RedundantSuppression"})
+    @Override
+    public long advance(long targetRowID) throws IOException
+    {
+        temp.clear();
+
+        while (!postingLists.isEmpty())
+        {
+            PeekablePostingList peekable = postingLists.poll();
+            peekable.advanceWithoutConsuming(targetRowID);
+            if (peekable.peek() != PostingList.END_OF_STREAM)
+                temp.add(peekable);
+        }
+        postingLists.addAll(temp);
+
+        return nextPosting();
+    }
+
+    @Override
+    public long size()
+    {
+        return size;
+    }
+
+    @Override
+    public void close()
+    {
+        FileUtils.closeQuietly(onClose);
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/postings/PostingListRangeIterator.java b/src/java/org/apache/cassandra/index/sai/disk/v1/postings/PostingListRangeIterator.java
new file mode 100644
index 0000000000..354663a30d
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/postings/PostingListRangeIterator.java
@@ -0,0 +1,171 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk.v1.postings;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.concurrent.TimeUnit;
+import javax.annotation.concurrent.NotThreadSafe;
+
+import com.google.common.base.Stopwatch;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.cassandra.exceptions.QueryCancelledException;
+import org.apache.cassandra.index.sai.IndexContext;
+import org.apache.cassandra.index.sai.QueryContext;
+import org.apache.cassandra.index.sai.disk.PrimaryKeyMap;
+import org.apache.cassandra.index.sai.disk.v1.segment.IndexSegmentSearcherContext;
+import org.apache.cassandra.index.sai.iterators.KeyRangeIterator;
+import org.apache.cassandra.index.sai.postings.PostingList;
+import org.apache.cassandra.index.sai.utils.PrimaryKey;
+import org.apache.cassandra.io.util.FileUtils;
+import org.apache.cassandra.utils.Throwables;
+
+/**
+ * A key iterator based on a  {@link PostingList} derived from a single index segment.
+ *
+ * <ol>
+ *   <li> fetch next segment row id from posting list or skip to specific segment row id if {@link #skipTo(PrimaryKey)} is called </li>
+ *   <li> add {@link IndexSegmentSearcherContext#segmentRowIdOffset} to obtain the sstable row id </li>
+ *   <li> produce a {@link PrimaryKey} from {@link PrimaryKeyMap#primaryKeyFromRowId(long)} which is used
+ *       to avoid fetching duplicated keys due to partition-level indexing on wide partition schema.
+ *       <br/>
+ *       Note: in order to reduce disk access in multi-index query, partition keys will only be fetched for intersected tokens
+ *       in {@link org.apache.cassandra.index.sai.plan.StorageAttachedIndexSearcher}.
+ *  </li>
+ * </ol>
+ *
+ */
+
+@NotThreadSafe
+public class PostingListRangeIterator extends KeyRangeIterator
+{
+    private static final Logger logger = LoggerFactory.getLogger(PostingListRangeIterator.class);
+
+    private final Stopwatch timeToExhaust = Stopwatch.createStarted();
+    private final QueryContext queryContext;
+
+    private final PostingList postingList;
+    private final IndexContext indexContext;
+    private final PrimaryKeyMap primaryKeyMap;
+    private final IndexSegmentSearcherContext searcherContext;
+
+    private boolean needsSkipping = false;
+    private PrimaryKey skipToToken = null;
+
+
+    /**
+     * Create a direct PostingListRangeIterator where the underlying PostingList is materialised
+     * immediately so the posting list size can be used.
+     */
+    public PostingListRangeIterator(IndexContext indexContext,
+                                    PrimaryKeyMap primaryKeyMap,
+                                    IndexSegmentSearcherContext searcherContext)
+    {
+        super(searcherContext.minimumKey, searcherContext.maximumKey, searcherContext.count());
+
+        this.indexContext = indexContext;
+        this.primaryKeyMap = primaryKeyMap;
+        this.postingList = searcherContext.postingList;
+        this.searcherContext = searcherContext;
+        this.queryContext = searcherContext.context;
+    }
+
+    @Override
+    protected void performSkipTo(PrimaryKey nextKey)
+    {
+        if (skipToToken != null && skipToToken.compareTo(nextKey) >= 0)
+            return;
+
+        skipToToken = nextKey;
+        needsSkipping = true;
+    }
+
+    @Override
+    protected PrimaryKey computeNext()
+    {
+        try
+        {
+            queryContext.checkpoint();
+
+            // just end the iterator if we don't have a postingList or current segment is skipped
+            if (exhausted())
+                return endOfData();
+
+            long rowId = getNextRowId();
+            if (rowId == PostingList.END_OF_STREAM)
+                return endOfData();
+
+            return primaryKeyMap.primaryKeyFromRowId(rowId);
+        }
+        catch (Throwable t)
+        {
+            if (!(t instanceof QueryCancelledException))
+                logger.error(indexContext.logMessage("Unable to provide next token!"), t);
+
+            throw Throwables.cleaned(t);
+        }
+    }
+
+    @Override
+    public void close()
+    {
+        if (logger.isTraceEnabled())
+        {
+            final long exhaustedInMills = timeToExhaust.stop().elapsed(TimeUnit.MILLISECONDS);
+            logger.trace(indexContext.logMessage("PostingListRangeIterator exhausted after {} ms"), exhaustedInMills);
+        }
+
+        FileUtils.closeQuietly(Arrays.asList(postingList, primaryKeyMap));
+    }
+
+    private boolean exhausted()
+    {
+        return needsSkipping && skipToToken.compareTo(getMaximum()) > 0;
+    }
+
+    /**
+     * reads the next sstable row ID from the underlying posting list, potentially skipping to get there.
+     */
+    private long getNextRowId() throws IOException
+    {
+        long segmentRowId;
+        if (needsSkipping)
+        {
+            long targetRowID = primaryKeyMap.rowIdFromPrimaryKey(skipToToken);
+            // skipToToken is larger than max token in token file
+            if (targetRowID < 0)
+            {
+                return PostingList.END_OF_STREAM;
+            }
+
+            segmentRowId = postingList.advance(targetRowID - searcherContext.segmentRowIdOffset);
+
+            needsSkipping = false;
+        }
+        else
+        {
+            segmentRowId = postingList.nextPosting();
+        }
+
+        return segmentRowId != PostingList.END_OF_STREAM
+               ? segmentRowId + searcherContext.segmentRowIdOffset
+               : PostingList.END_OF_STREAM;
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/postings/PostingsReader.java b/src/java/org/apache/cassandra/index/sai/disk/v1/postings/PostingsReader.java
new file mode 100644
index 0000000000..de82ebd01c
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/postings/PostingsReader.java
@@ -0,0 +1,366 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk.v1.postings;
+
+
+import java.io.IOException;
+import javax.annotation.concurrent.NotThreadSafe;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import org.apache.cassandra.index.sai.postings.OrdinalPostingList;
+import org.apache.cassandra.index.sai.postings.PostingList;
+import org.apache.cassandra.index.sai.disk.v1.DirectReaders;
+import org.apache.cassandra.index.sai.disk.v1.LongArray;
+import org.apache.cassandra.index.sai.metrics.QueryEventListener;
+import org.apache.cassandra.index.sai.disk.io.SeekingRandomAccessInput;
+import org.apache.cassandra.io.util.FileUtils;
+import org.apache.lucene.index.CorruptIndexException;
+import org.apache.lucene.store.IndexInput;
+import org.apache.lucene.store.RandomAccessInput;
+
+
+/**
+ * Reads, decompresses and decodes postings lists written by {@link PostingsWriter}.
+ *
+ * Holds exactly one posting block in memory at a time. Does binary search over skip table to find a postings block to
+ * load.
+ */
+@NotThreadSafe
+public class PostingsReader implements OrdinalPostingList
+{
+    private final IndexInput input;
+    private final SeekingRandomAccessInput seekingInput;
+    private final QueryEventListener.PostingListEventListener listener;
+    private final BlocksSummary summary;
+
+    // Current block index
+    private int blockIndex;
+    // Current posting index within block
+    private int postingIndex;
+    private long totalPostingsRead;
+    private long actualPosting;
+
+    private long currentPosition;
+    private DirectReaders.Reader currentFoRValues;
+    private long postingsDecoded = 0;
+
+    @VisibleForTesting
+    public PostingsReader(IndexInput input, long summaryOffset, QueryEventListener.PostingListEventListener listener) throws IOException
+    {
+        this(input, new BlocksSummary(input, summaryOffset), listener);
+    }
+
+    public PostingsReader(IndexInput input, BlocksSummary summary, QueryEventListener.PostingListEventListener listener) throws IOException
+    {
+        this.input = input;
+        this.seekingInput = new SeekingRandomAccessInput(input);
+        this.listener = listener;
+        this.summary = summary;
+
+        reBuffer();
+    }
+
+    @Override
+    public long getOrdinal()
+    {
+        return totalPostingsRead;
+    }
+
+    public static class BlocksSummary
+    {
+        private final IndexInput input;
+        final int blockSize;
+        final int numPostings;
+        final LongArray offsets;
+        final LongArray maxValues;
+
+        public BlocksSummary(IndexInput input, long offset) throws IOException
+        {
+            this.input = input;
+            input.seek(offset);
+            this.blockSize = input.readVInt();
+            //TODO This should need to change because we can potentially end up with postings of more than Integer.MAX_VALUE?
+            this.numPostings = input.readVInt();
+
+            SeekingRandomAccessInput randomAccessInput = new SeekingRandomAccessInput(input);
+            int numBlocks = input.readVInt();
+            long maxBlockValuesLength = input.readVLong();
+            long maxBlockValuesOffset = input.getFilePointer() + maxBlockValuesLength;
+
+            byte offsetBitsPerValue = input.readByte();
+            if (!DirectReaders.SUPPORTED_BITS_PER_VALUE.contains((int)offsetBitsPerValue))
+            {
+                throw new CorruptIndexException(
+                        String.format("Postings list header is corrupted: Bits per value for block offsets is %s. Supported values are %s.",
+                                      offsetBitsPerValue, DirectReaders.SUPPORTED_BITS_PER_VALUE_STRING), input);
+            }
+            this.offsets = new LongArrayReader(randomAccessInput, DirectReaders.getReaderForBitsPerValue(offsetBitsPerValue), input.getFilePointer(), numBlocks);
+
+            input.seek(maxBlockValuesOffset);
+            byte valuesBitsPerValue = input.readByte();
+            if (!DirectReaders.SUPPORTED_BITS_PER_VALUE.contains((int)valuesBitsPerValue))
+            {
+                throw new CorruptIndexException(
+                String.format("Postings list header is corrupted: Bits per value for value samples is %s. Supported values are %s.",
+                              valuesBitsPerValue, DirectReaders.SUPPORTED_BITS_PER_VALUE_STRING), input);
+            }
+            this.maxValues = new LongArrayReader(randomAccessInput, DirectReaders.getReaderForBitsPerValue(valuesBitsPerValue), input.getFilePointer(), numBlocks);
+        }
+
+        void close()
+        {
+            FileUtils.closeQuietly(input);
+        }
+
+        private static class LongArrayReader implements LongArray
+        {
+            private final RandomAccessInput input;
+            private final DirectReaders.Reader reader;
+            private final long offset;
+            private final int length;
+
+            private LongArrayReader(RandomAccessInput input, DirectReaders.Reader reader, long offset, int length)
+            {
+                this.input = input;
+                this.reader = reader;
+                this.offset = offset;
+                this.length = length;
+            }
+
+            @Override
+            public long get(long idx)
+            {
+                return reader.get(input, offset, idx);
+            }
+
+            @Override
+            public long length()
+            {
+                return length;
+            }
+        }
+    }
+
+    @Override
+    public void close()
+    {
+        listener.postingDecoded(postingsDecoded);
+        FileUtils.closeQuietly(input);
+        summary.close();
+    }
+
+    @Override
+    public long size()
+    {
+        return summary.numPostings;
+    }
+
+    /**
+     * Advances to the first row ID beyond the current that is greater than or equal to the
+     * target, and returns that row ID. Exhausts the iterator and returns {@link #END_OF_STREAM} if
+     * the target is greater than the highest row ID.
+     *
+     * Does binary search over the skip table to find the next block to load into memory.
+     *
+     * Note: Callers must use the return value of this method before calling {@link #nextPosting()}, as calling
+     * that method will return the next posting, not the one to which we have just advanced.
+     *
+     * @param targetRowID target row ID to advance to
+     *
+     * @return first segment row ID which is >= the target row ID or {@link PostingList#END_OF_STREAM} if one does not exist
+     */
+    @Override
+    public long advance(long targetRowID) throws IOException
+    {
+        listener.onAdvance();
+        int block = binarySearchBlocks(targetRowID);
+
+        if (block < 0)
+        {
+            block = -block - 1;
+        }
+
+        if (blockIndex == block + 1)
+        {
+            // we're in the same block, just iterate through
+            return slowAdvance(targetRowID);
+        }
+        assert block > 0;
+        // Even if there was an exact match, block might contain duplicates.
+        // We iterate to the target token from the beginning.
+        lastPosInBlock(block - 1);
+        return slowAdvance(targetRowID);
+    }
+
+    private long slowAdvance(long targetRowID) throws IOException
+    {
+        while (totalPostingsRead < summary.numPostings)
+        {
+            long segmentRowId = peekNext();
+
+            advanceOnePosition(segmentRowId);
+
+            if (segmentRowId >= targetRowID)
+            {
+                return segmentRowId;
+            }
+        }
+        return END_OF_STREAM;
+    }
+
+    // Perform a binary search of the blocks to the find the block index
+    // containing the targetRowID, or, in the case of a duplicate value
+    // crossing blocks, the preceeding block index
+    private int binarySearchBlocks(long targetRowID)
+    {
+        int lowBlockIndex = blockIndex - 1;
+        int highBlockIndex = Math.toIntExact(summary.maxValues.length()) - 1;
+
+        // in current block
+        if (lowBlockIndex <= highBlockIndex && targetRowID <= summary.maxValues.get(lowBlockIndex))
+            return lowBlockIndex;
+
+        while (lowBlockIndex <= highBlockIndex)
+        {
+            int midBlockIndex = lowBlockIndex + ((highBlockIndex - lowBlockIndex) >> 1) ;
+
+            long maxValueOfMidBlock = summary.maxValues.get(midBlockIndex);
+
+            if (maxValueOfMidBlock < targetRowID)
+            {
+                lowBlockIndex = midBlockIndex + 1;
+            }
+            else if (maxValueOfMidBlock > targetRowID)
+            {
+                highBlockIndex = midBlockIndex - 1;
+            }
+            else
+            {
+                // At this point the maximum value of the midway block matches our target.
+                //
+                // This following check is to see if we have a duplicate value in the last entry of the
+                // preceeding block. This check is only going to be successful if the entire current
+                // block is full of duplicates.
+                if (midBlockIndex > 0 && summary.maxValues.get(midBlockIndex - 1) == targetRowID)
+                {
+                    // there is a duplicate in the preceeding block so restrict search to finish
+                    // at that block
+                    highBlockIndex = midBlockIndex - 1;
+                }
+                else
+                {
+                    // no duplicates
+                    return midBlockIndex;
+                }
+            }
+        }
+        return -(lowBlockIndex + 1);  // target not found
+    }
+
+    private void lastPosInBlock(int block)
+    {
+        // blockMaxValues is integer only
+        actualPosting = summary.maxValues.get(block);
+        //upper bound, since we might've advanced to the last block, but upper bound is enough
+        totalPostingsRead += (summary.blockSize - postingIndex) + (block - blockIndex + 1) * (long)summary.blockSize;
+
+        blockIndex = block + 1;
+        postingIndex = summary.blockSize;
+    }
+
+    @Override
+    public long nextPosting() throws IOException
+    {
+        long next = peekNext();
+        if (next != END_OF_STREAM)
+        {
+            advanceOnePosition(next);
+        }
+        return next;
+    }
+
+    private long peekNext() throws IOException
+    {
+        if (totalPostingsRead >= summary.numPostings)
+        {
+            return END_OF_STREAM;
+        }
+        if (postingIndex == summary.blockSize)
+        {
+            reBuffer();
+        }
+
+        return actualPosting + nextFoRValue();
+    }
+
+    private int nextFoRValue()
+    {
+        // currentFoRValues is null when the all the values in the block are the same
+        if (currentFoRValues == null)
+        {
+            return 0;
+        }
+        else
+        {
+            long id = currentFoRValues.get(seekingInput, currentPosition, postingIndex);
+            postingsDecoded++;
+            return Math.toIntExact(id);
+        }
+    }
+
+    private void advanceOnePosition(long nextPosting)
+    {
+        actualPosting = nextPosting;
+        totalPostingsRead++;
+        postingIndex++;
+    }
+
+    private void reBuffer() throws IOException
+    {
+        long pointer = summary.offsets.get(blockIndex);
+
+        input.seek(pointer);
+
+        long left = summary.numPostings - totalPostingsRead;
+        assert left > 0;
+
+        readFoRBlock(input);
+
+        blockIndex++;
+        postingIndex = 0;
+    }
+
+    private void readFoRBlock(IndexInput in) throws IOException
+    {
+        if (blockIndex == 0)
+            actualPosting = in.readVLong();
+
+        byte bitsPerValue = in.readByte();
+
+        currentPosition = in.getFilePointer();
+
+        if (bitsPerValue == 0)
+        {
+            // currentFORValues is null when the all the values in the block are the same
+            currentFoRValues = null;
+            return;
+        }
+        currentFoRValues = DirectReaders.getReaderForBitsPerValue(bitsPerValue);
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/postings/PostingsWriter.java b/src/java/org/apache/cassandra/index/sai/disk/v1/postings/PostingsWriter.java
new file mode 100644
index 0000000000..d441942fc4
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/postings/PostingsWriter.java
@@ -0,0 +1,316 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk.v1.postings;
+
+
+import java.io.Closeable;
+import java.io.IOException;
+import javax.annotation.concurrent.NotThreadSafe;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import org.agrona.collections.LongArrayList;
+import org.apache.cassandra.index.sai.IndexContext;
+import org.apache.cassandra.index.sai.disk.v1.DirectReaders;
+import org.apache.cassandra.index.sai.postings.PostingList;
+import org.apache.cassandra.index.sai.disk.format.IndexComponent;
+import org.apache.cassandra.index.sai.disk.format.IndexDescriptor;
+import org.apache.cassandra.index.sai.disk.io.RAMIndexOutput;
+import org.apache.cassandra.index.sai.disk.v1.SAICodecUtils;
+import org.apache.lucene.store.DataOutput;
+import org.apache.lucene.store.IndexOutput;
+import org.apache.lucene.util.packed.DirectWriter;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.lang.Math.max;
+import static org.apache.lucene.codecs.lucene50.Lucene50PostingsFormat.BLOCK_SIZE;
+
+/**
+ * Encodes, compresses and writes postings lists to disk.
+ *
+ * All postings in the posting list are delta encoded, then deltas are divided into blocks for compression.
+ * The deltas are based on the final value of the previous block. For the first block in the posting list
+ * the first value in the block is written as a VLong prior to block delta encodings.
+ * <p>
+ * In packed blocks, longs are encoded with the same bit width (FoR compression). The block size (i.e. number of
+ * longs inside block) is fixed (currently 128). Additionally blocks that are all the same value are encoded in an
+ * optimized way.
+ * </p>
+ * <p>
+ * In VLong blocks, longs are compressed with {@link DataOutput#writeVLong}. The block size is variable.
+ * </p>
+ *
+ * <p>
+ * Packed blocks are favoured, meaning when the postings are long enough, {@link PostingsWriter} will try
+ * to encode most data as a packed block. Take a term with 259 postings as an example, the first 256 postings are encoded
+ * as two packed blocks, while the remaining 3 are encoded as one VLong block.
+ * </p>
+ * <p>
+ * Each posting list ends with a block summary containing metadata and a skip table, written right after all postings
+ * blocks. Skip interval is the same as block size, and each skip entry points to the end of each block.
+ * Skip table consist of block offsets and last values of each block, compressed as two FoR blocks.
+ * </p>
+ *
+ * Visual representation of the disk format:
+ * <pre>
+ *
+ * +========+========================+=====+==============+===============+===============+=====+========================+========+
+ * | HEADER | POSTINGS LIST (TERM 1)                                                      | ... | POSTINGS LIST (TERM N) | FOOTER |
+ * +========+========================+=====+==============+===============+===============+=====+========================+========+
+ *          | FIRST VALUE| FOR BLOCK (1)| ... | FOR BLOCK (N)| BLOCK SUMMARY              |
+ *          +---------------------------+-----+--------------+---------------+------------+
+ *                                                           | BLOCK SIZE    |            |
+ *                                                           | LIST SIZE     | SKIP TABLE |
+ *                                                           +---------------+------------+
+ *                                                                           | BLOCKS POS.|
+ *                                                                           | MAX VALUES |
+ *                                                                           +------------+
+ *
+ *  </pre>
+ */
+@NotThreadSafe
+public class PostingsWriter implements Closeable
+{
+    private static final String POSTINGS_MUST_BE_SORTED_ERROR_MSG = "Postings must be sorted ascending, got [%s] after [%s]";
+
+    private final IndexOutput dataOutput;
+    private final int blockSize;
+    private final long[] deltaBuffer;
+    private final LongArrayList blockOffsets = new LongArrayList();
+    private final LongArrayList blockMaximumPostings = new LongArrayList();
+    private final RAMIndexOutput inMemoryOutput = new RAMIndexOutput("blockOffsets");
+
+    private final long startOffset;
+
+    private int bufferUpto;
+    private long firstPosting = Long.MIN_VALUE;
+    private long lastPosting = Long.MIN_VALUE;
+    private long maxDelta;
+    private long totalPostings;
+
+    public PostingsWriter(IndexDescriptor indexDescriptor, IndexContext indexContext) throws IOException
+    {
+        this(indexDescriptor, indexContext, BLOCK_SIZE);
+    }
+
+    @VisibleForTesting
+    PostingsWriter(IndexDescriptor indexDescriptor, IndexContext indexContext, int blockSize) throws IOException
+    {
+        this(indexDescriptor.openPerIndexOutput(IndexComponent.POSTING_LISTS, indexContext, true), blockSize);
+    }
+
+    private PostingsWriter(IndexOutput dataOutput, int blockSize) throws IOException
+    {
+        this.blockSize = blockSize;
+        this.dataOutput = dataOutput;
+        startOffset = dataOutput.getFilePointer();
+        deltaBuffer = new long[blockSize];
+        SAICodecUtils.writeHeader(dataOutput);
+    }
+
+    /**
+     * @return current file pointer
+     */
+    public long getFilePointer()
+    {
+        return dataOutput.getFilePointer();
+    }
+
+    /**
+     * @return file pointer where index structure begins
+     */
+    public long getStartOffset()
+    {
+        return startOffset;
+    }
+
+    /**
+     * write footer to the postings
+     */
+    public void complete() throws IOException
+    {
+        SAICodecUtils.writeFooter(dataOutput);
+    }
+
+    @Override
+    public void close() throws IOException
+    {
+        dataOutput.close();
+    }
+
+    /**
+     * Encodes, compresses and flushes given posting list to disk.
+     *
+     * @param postings posting list to write to disk
+     *
+     * @return file offset to the summary block of this posting list
+     */
+    public long write(PostingList postings) throws IOException
+    {
+        checkArgument(postings != null, "Expected non-null posting list.");
+        checkArgument(postings.size() > 0, "Expected non-empty posting list.");
+
+        lastPosting = Long.MIN_VALUE;
+        resetBlockCounters();
+        blockOffsets.clear();
+        blockMaximumPostings.clear();
+
+        long posting;
+        // When postings list are merged, we don't know exact size, just an upper bound.
+        // We need to count how many postings we added to the block ourselves.
+        int size = 0;
+        while ((posting = postings.nextPosting()) != PostingList.END_OF_STREAM)
+        {
+            writePosting(posting);
+            size++;
+            totalPostings++;
+        }
+
+        assert size > 0 : "No postings were written";
+
+        finish();
+
+        final long summaryOffset = dataOutput.getFilePointer();
+        writeSummary(size);
+        return summaryOffset;
+    }
+
+    public long getTotalPostings()
+    {
+        return totalPostings;
+    }
+
+    private void writePosting(long posting) throws IOException
+    {
+        if (lastPosting == Long.MIN_VALUE)
+        {
+            firstPosting = posting;
+            deltaBuffer[bufferUpto++] = 0;
+        }
+        else
+        {
+            if (posting < lastPosting)
+                throw new IllegalArgumentException(String.format(POSTINGS_MUST_BE_SORTED_ERROR_MSG, posting, lastPosting));
+            long delta = posting - lastPosting;
+            maxDelta = max(maxDelta, delta);
+            deltaBuffer[bufferUpto++] = delta;
+        }
+        lastPosting = posting;
+
+        if (bufferUpto == blockSize)
+        {
+            addBlockToSkipTable();
+            writePostingsBlock();
+            resetBlockCounters();
+        }
+    }
+
+    private void finish() throws IOException
+    {
+        if (bufferUpto > 0)
+        {
+            addBlockToSkipTable();
+            writePostingsBlock();
+        }
+    }
+
+    private void resetBlockCounters()
+    {
+        firstPosting = Long.MIN_VALUE;
+        bufferUpto = 0;
+        maxDelta = 0;
+    }
+
+    private void addBlockToSkipTable()
+    {
+        blockOffsets.add(dataOutput.getFilePointer());
+        blockMaximumPostings.add(lastPosting);
+    }
+
+    private void writeSummary(int exactSize) throws IOException
+    {
+        dataOutput.writeVInt(blockSize);
+        dataOutput.writeVInt(exactSize);
+        writeSkipTable();
+    }
+
+    private void writeSkipTable() throws IOException
+    {
+        assert blockOffsets.size() == blockMaximumPostings.size();
+        dataOutput.writeVInt(blockOffsets.size());
+
+        // compressing offsets in memory first, to know the exact length (with padding)
+        inMemoryOutput.reset();
+
+        writeSortedFoRBlock(blockOffsets, inMemoryOutput);
+        dataOutput.writeVLong(inMemoryOutput.getFilePointer());
+        inMemoryOutput.writeTo(dataOutput);
+        writeSortedFoRBlock(blockMaximumPostings, dataOutput);
+    }
+
+    private void writePostingsBlock() throws IOException
+    {
+        final int bitsPerValue = maxDelta == 0 ? 0 : DirectWriter.unsignedBitsRequired(maxDelta);
+
+        assert DirectReaders.SUPPORTED_BITS_PER_VALUE.contains(bitsPerValue) :
+        "Unsupported bits per value of " + bitsPerValue + " bits. Supported values are: " + DirectReaders.SUPPORTED_BITS_PER_VALUE_STRING;
+
+        // If we have a first posting, indicating that this is the first block in the posting list
+        // then write it prior to the deltas.
+        if (firstPosting != Long.MIN_VALUE)
+            dataOutput.writeVLong(firstPosting);
+
+        dataOutput.writeByte((byte) bitsPerValue);
+        if (bitsPerValue > 0)
+        {
+            final DirectWriter writer = DirectWriter.getInstance(dataOutput, blockSize, bitsPerValue);
+            for (int index = 0; index < bufferUpto; ++index)
+            {
+                writer.add(deltaBuffer[index]);
+            }
+            if (bufferUpto < blockSize)
+            {
+                // Pad the rest of the block with 0, so we don't write invalid
+                // values from previous blocks
+                for (int index = bufferUpto; index < blockSize; index++)
+                {
+                    writer.add(0);
+                }
+            }
+            writer.finish();
+        }
+    }
+
+    private void writeSortedFoRBlock(LongArrayList values, IndexOutput output) throws IOException
+    {
+        final long maxValue = values.getLong(values.size() - 1);
+
+        assert values.size() > 0;
+        final int bitsPerValue = maxValue == 0 ? 0 : DirectWriter.unsignedBitsRequired(maxValue);
+        output.writeByte((byte) bitsPerValue);
+        if (bitsPerValue > 0)
+        {
+            final DirectWriter writer = DirectWriter.getInstance(output, values.size(), bitsPerValue);
+            for (int i = 0; i < values.size(); ++i)
+            {
+                writer.add(values.getLong(i));
+            }
+            writer.finish();
+        }
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/segment/IndexSegmentSearcher.java b/src/java/org/apache/cassandra/index/sai/disk/v1/segment/IndexSegmentSearcher.java
new file mode 100644
index 0000000000..bb22ff492b
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/segment/IndexSegmentSearcher.java
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk.v1.segment;
+
+import java.io.Closeable;
+import java.io.IOException;
+
+import org.apache.cassandra.index.sai.IndexContext;
+import org.apache.cassandra.index.sai.QueryContext;
+import org.apache.cassandra.index.sai.disk.PrimaryKeyMap;
+import org.apache.cassandra.index.sai.disk.v1.PerColumnIndexFiles;
+import org.apache.cassandra.index.sai.disk.v1.postings.PostingListRangeIterator;
+import org.apache.cassandra.index.sai.iterators.KeyRangeIterator;
+import org.apache.cassandra.index.sai.plan.Expression;
+import org.apache.cassandra.index.sai.postings.PeekablePostingList;
+import org.apache.cassandra.index.sai.postings.PostingList;
+
+/**
+ * Abstract reader for individual segments of an on-disk index.
+ *
+ * Accepts shared resources (token/offset file readers), and uses them to perform lookups against on-disk data
+ * structures.
+ */
+public abstract class IndexSegmentSearcher implements Closeable
+{
+    final PrimaryKeyMap.Factory primaryKeyMapFactory;
+    final PerColumnIndexFiles indexFiles;
+    final SegmentMetadata metadata;
+    final IndexContext indexContext;
+
+    IndexSegmentSearcher(PrimaryKeyMap.Factory primaryKeyMapFactory,
+                         PerColumnIndexFiles perIndexFiles,
+                         SegmentMetadata segmentMetadata,
+                         IndexContext indexContext)
+    {
+        this.primaryKeyMapFactory = primaryKeyMapFactory;
+        this.indexFiles = perIndexFiles;
+        this.metadata = segmentMetadata;
+        this.indexContext = indexContext;
+    }
+
+    public static IndexSegmentSearcher open(PrimaryKeyMap.Factory primaryKeyMapFactory,
+                                            PerColumnIndexFiles indexFiles,
+                                            SegmentMetadata segmentMetadata,
+                                            IndexContext indexContext) throws IOException
+    {
+        return new LiteralIndexSegmentSearcher(primaryKeyMapFactory, indexFiles, segmentMetadata, indexContext);
+    }
+
+    /**
+     * @return memory usage of underlying on-disk data structure
+     */
+    public abstract long indexFileCacheSize();
+
+    /**
+     * Search on-disk index synchronously.
+     *
+     * @param expression to filter on disk index
+     * @param queryContext to track per sstable cache and per query metrics
+     *
+     * @return {@link KeyRangeIterator} with matches for the given expression
+     */
+    public abstract KeyRangeIterator search(Expression expression, QueryContext queryContext) throws IOException;
+
+    KeyRangeIterator toIterator(PostingList postingList, QueryContext queryContext) throws IOException
+    {
+        if (postingList == null)
+            return KeyRangeIterator.empty();
+
+        IndexSegmentSearcherContext searcherContext = new IndexSegmentSearcherContext(metadata.minKey,
+                                                                                      metadata.maxKey,
+                                                                                      metadata.rowIdOffset,
+                                                                                      queryContext,
+                                                                                      PeekablePostingList.makePeekable(postingList));
+
+        return new PostingListRangeIterator(indexContext, primaryKeyMapFactory.newPerSSTablePrimaryKeyMap(), searcherContext);
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/segment/IndexSegmentSearcherContext.java b/src/java/org/apache/cassandra/index/sai/disk/v1/segment/IndexSegmentSearcherContext.java
new file mode 100644
index 0000000000..5b3c59b0ec
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/segment/IndexSegmentSearcherContext.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.index.sai.disk.v1.segment;
+
+import org.apache.cassandra.index.sai.QueryContext;
+import org.apache.cassandra.index.sai.postings.PeekablePostingList;
+import org.apache.cassandra.index.sai.utils.PrimaryKey;
+
+public class IndexSegmentSearcherContext
+{
+    public final QueryContext context;
+    public final PeekablePostingList postingList;
+
+    public final PrimaryKey minimumKey;
+    public final PrimaryKey maximumKey;
+    public final long segmentRowIdOffset;
+
+    public IndexSegmentSearcherContext(PrimaryKey minimumKey,
+                                       PrimaryKey maximumKey,
+                                       long segmentRowIdOffset,
+                                       QueryContext context,
+                                       PeekablePostingList postingList)
+    {
+        this.context = context;
+        this.postingList = postingList;
+
+        this.segmentRowIdOffset = segmentRowIdOffset;
+
+        this.minimumKey = minimumKey;
+
+        // use segment's metadata for the range iterator, may not be accurate, but should not matter to performance.
+        this.maximumKey = maximumKey;
+    }
+
+    public long count()
+    {
+        return postingList.size();
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/segment/LiteralIndexSegmentSearcher.java b/src/java/org/apache/cassandra/index/sai/disk/v1/segment/LiteralIndexSegmentSearcher.java
new file mode 100644
index 0000000000..fb45943934
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/segment/LiteralIndexSegmentSearcher.java
@@ -0,0 +1,109 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.index.sai.disk.v1.segment;
+
+import java.io.IOException;
+import java.util.Map;
+
+import com.google.common.base.MoreObjects;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.cassandra.index.sai.IndexContext;
+import org.apache.cassandra.index.sai.QueryContext;
+import org.apache.cassandra.index.sai.disk.PrimaryKeyMap;
+import org.apache.cassandra.index.sai.disk.format.IndexComponent;
+import org.apache.cassandra.index.sai.disk.v1.PerColumnIndexFiles;
+import org.apache.cassandra.index.sai.disk.v1.SAICodecUtils;
+import org.apache.cassandra.index.sai.metrics.MulticastQueryEventListeners;
+import org.apache.cassandra.index.sai.metrics.QueryEventListener;
+import org.apache.cassandra.index.sai.plan.Expression;
+import org.apache.cassandra.index.sai.postings.PostingList;
+import org.apache.cassandra.index.sai.iterators.KeyRangeIterator;
+import org.apache.cassandra.utils.bytecomparable.ByteComparable;
+
+/**
+ * Executes {@link Expression}s against the trie-based terms dictionary for an individual index segment.
+ */
+public class LiteralIndexSegmentSearcher extends IndexSegmentSearcher
+{
+    private static final Logger logger = LoggerFactory.getLogger(LiteralIndexSegmentSearcher.class);
+
+    private final LiteralIndexSegmentTermsReader reader;
+    private final QueryEventListener.TrieIndexEventListener perColumnEventListener;
+
+    LiteralIndexSegmentSearcher(PrimaryKeyMap.Factory primaryKeyMapFactory,
+                                PerColumnIndexFiles perIndexFiles,
+                                SegmentMetadata segmentMetadata,
+                                IndexContext indexContext) throws IOException
+    {
+        super(primaryKeyMapFactory, perIndexFiles, segmentMetadata, indexContext);
+
+        long root = metadata.getIndexRoot(IndexComponent.TERMS_DATA);
+        assert root >= 0;
+
+        perColumnEventListener = (QueryEventListener.TrieIndexEventListener)indexContext.getColumnQueryMetrics();
+
+        Map<String,String> map = metadata.componentMetadatas.get(IndexComponent.TERMS_DATA).attributes;
+        String footerPointerString = map.get(SAICodecUtils.FOOTER_POINTER);
+        long footerPointer = footerPointerString == null ? -1 : Long.parseLong(footerPointerString);
+
+        reader = new LiteralIndexSegmentTermsReader(indexContext,
+                                                    indexFiles.termsData(),
+                                                    indexFiles.postingLists(),
+                                                    root, footerPointer);
+    }
+
+    @Override
+    public long indexFileCacheSize()
+    {
+        // trie has no pre-allocated memory.
+        return 0;
+    }
+
+    @Override
+    @SuppressWarnings({"resource", "RedundantSuppression"})
+    public KeyRangeIterator search(Expression expression, QueryContext queryContext) throws IOException
+    {
+        if (logger.isTraceEnabled())
+            logger.trace(indexContext.logMessage("Searching on expression '{}'..."), expression);
+
+        if (!expression.getOp().isEquality())
+            throw new IllegalArgumentException(indexContext.logMessage("Unsupported expression: " + expression));
+
+        final ByteComparable term = ByteComparable.fixedLength(expression.lower.value.encoded);
+        QueryEventListener.TrieIndexEventListener listener = MulticastQueryEventListeners.of(queryContext, perColumnEventListener);
+        PostingList postingList = reader.exactMatch(term, listener, queryContext);
+        return toIterator(postingList, queryContext);
+    }
+
+    @Override
+    public String toString()
+    {
+        return MoreObjects.toStringHelper(this)
+                          .add("indexContext", indexContext)
+                          .toString();
+    }
+
+    @Override
+    public void close()
+    {
+        reader.close();
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/segment/LiteralIndexSegmentTermsReader.java b/src/java/org/apache/cassandra/index/sai/disk/v1/segment/LiteralIndexSegmentTermsReader.java
new file mode 100644
index 0000000000..abca7c95cb
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/segment/LiteralIndexSegmentTermsReader.java
@@ -0,0 +1,175 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk.v1.segment;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+
+import com.google.common.annotations.VisibleForTesting;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.cassandra.exceptions.QueryCancelledException;
+import org.apache.cassandra.index.sai.IndexContext;
+import org.apache.cassandra.index.sai.QueryContext;
+import org.apache.cassandra.index.sai.disk.io.IndexFileUtils;
+import org.apache.cassandra.index.sai.disk.v1.postings.PostingsReader;
+import org.apache.cassandra.index.sai.disk.v1.trie.TrieTermsDictionaryReader;
+import org.apache.cassandra.index.sai.metrics.QueryEventListener;
+import org.apache.cassandra.index.sai.postings.PostingList;
+import org.apache.cassandra.io.util.FileHandle;
+import org.apache.cassandra.io.util.FileUtils;
+import org.apache.cassandra.utils.Clock;
+import org.apache.cassandra.utils.Throwables;
+import org.apache.cassandra.utils.bytecomparable.ByteComparable;
+import org.apache.lucene.store.IndexInput;
+
+import static org.apache.cassandra.index.sai.disk.v1.SAICodecUtils.validate;
+
+/**
+ * Synchronous reader of terms dictionary and postings lists to produce a {@link PostingList} with matching row ids.
+ *
+ * {@link #exactMatch(ByteComparable, QueryEventListener.TrieIndexEventListener, QueryContext)} does:
+ * <ul>
+ * <li>{@link TermQuery#lookupPostingsOffset(ByteComparable)}: does term dictionary lookup to find the posting list file
+ * position</li>
+ * <li>{@link TermQuery#getPostingsReader(long)}: reads posting list block summary and initializes posting read which
+ * reads the first block of the posting list into memory</li>
+ * </ul>
+ */
+public class LiteralIndexSegmentTermsReader implements Closeable
+{
+    private static final Logger logger = LoggerFactory.getLogger(LiteralIndexSegmentTermsReader.class);
+
+    private final IndexContext indexContext;
+    private final FileHandle termDictionaryFile;
+    private final FileHandle postingsFile;
+    private final long termDictionaryRoot;
+
+    public LiteralIndexSegmentTermsReader(IndexContext indexContext,
+                                          FileHandle termsData,
+                                          FileHandle postingLists,
+                                          long root,
+                                          long termsFooterPointer) throws IOException
+    {
+        this.indexContext = indexContext;
+        termDictionaryFile = termsData;
+        postingsFile = postingLists;
+        termDictionaryRoot = root;
+
+        try (final IndexInput indexInput = IndexFileUtils.instance.openInput(termDictionaryFile))
+        {
+            validate(indexInput, termsFooterPointer);
+        }
+
+        try (final IndexInput indexInput = IndexFileUtils.instance.openInput(postingsFile))
+        {
+            validate(indexInput);
+        }
+    }
+
+    @Override
+    public void close()
+    {
+        FileUtils.closeQuietly(termDictionaryFile);
+        FileUtils.closeQuietly(postingsFile);
+    }
+
+    public PostingList exactMatch(ByteComparable term, QueryEventListener.TrieIndexEventListener perQueryEventListener, QueryContext context)
+    {
+        perQueryEventListener.onSegmentHit();
+        return new TermQuery(term, perQueryEventListener, context).execute();
+    }
+
+    @VisibleForTesting
+    public class TermQuery
+    {
+        private final IndexInput postingsInput;
+        private final IndexInput postingsSummaryInput;
+        private final QueryEventListener.TrieIndexEventListener listener;
+        private final long lookupStartTime;
+        private final QueryContext context;
+        private final ByteComparable term;
+
+        TermQuery(ByteComparable term, QueryEventListener.TrieIndexEventListener listener, QueryContext context)
+        {
+            this.listener = listener;
+            postingsInput = IndexFileUtils.instance.openInput(postingsFile);
+            postingsSummaryInput = IndexFileUtils.instance.openInput(postingsFile);
+            this.term = term;
+            lookupStartTime = Clock.Global.nanoTime();
+            this.context = context;
+        }
+
+        public PostingList execute()
+        {
+            try
+            {
+                long postingOffset = lookupPostingsOffset(term);
+                if (postingOffset == PostingList.OFFSET_NOT_FOUND)
+                {
+                    FileUtils.closeQuietly(postingsInput);
+                    FileUtils.closeQuietly(postingsSummaryInput);
+                    return null;
+                }
+
+                context.checkpoint();
+
+                // when posting is found, resources will be closed when posting reader is closed.
+                return getPostingsReader(postingOffset);
+            }
+            catch (Throwable e)
+            {
+                if (!(e instanceof QueryCancelledException))
+                    logger.error(indexContext.logMessage("Failed to execute term query"), e);
+
+                closeOnException();
+                throw Throwables.cleaned(e);
+            }
+        }
+
+        private void closeOnException()
+        {
+            FileUtils.closeQuietly(postingsInput);
+            FileUtils.closeQuietly(postingsSummaryInput);
+        }
+
+        public long lookupPostingsOffset(ByteComparable term)
+        {
+            try (TrieTermsDictionaryReader reader = new TrieTermsDictionaryReader(termDictionaryFile.instantiateRebufferer(null), termDictionaryRoot))
+            {
+                final long offset = reader.exactMatch(term);
+
+                listener.onTraversalComplete(Clock.Global.nanoTime() - lookupStartTime, TimeUnit.NANOSECONDS);
+
+                if (offset == TrieTermsDictionaryReader.NOT_FOUND)
+                    return PostingList.OFFSET_NOT_FOUND;
+
+                return offset;
+            }
+        }
+
+        public PostingsReader getPostingsReader(long offset) throws IOException
+        {
+            PostingsReader.BlocksSummary header = new PostingsReader.BlocksSummary(postingsSummaryInput, offset);
+
+            return new PostingsReader(postingsInput, header, listener.postingListEventListener());
+        }
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/segment/Segment.java b/src/java/org/apache/cassandra/index/sai/disk/v1/segment/Segment.java
new file mode 100644
index 0000000000..a36a3504dc
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/segment/Segment.java
@@ -0,0 +1,129 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk.v1.segment;
+
+import java.io.Closeable;
+import java.io.IOException;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import org.apache.cassandra.db.PartitionPosition;
+import org.apache.cassandra.dht.AbstractBounds;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.index.sai.IndexContext;
+import org.apache.cassandra.index.sai.QueryContext;
+import org.apache.cassandra.index.sai.SSTableContext;
+import org.apache.cassandra.index.sai.disk.PrimaryKeyMap;
+import org.apache.cassandra.index.sai.disk.v1.PerColumnIndexFiles;
+import org.apache.cassandra.index.sai.plan.Expression;
+import org.apache.cassandra.index.sai.iterators.KeyRangeIterator;
+import org.apache.cassandra.io.util.FileUtils;
+
+/**
+ * Each segment represents an on-disk index structure (kdtree/terms/postings) flushed by memory limit or token boundaries.
+ * It also helps to reduce resource consumption for read requests as only segments that intersect with read request data
+ * range need to be loaded.
+ */
+public class Segment implements Closeable
+{
+    private final Token minKey;
+    private final Token.KeyBound minKeyBound;
+    private final Token maxKey;
+    private final Token.KeyBound maxKeyBound;
+
+    // per sstable
+    final PrimaryKeyMap.Factory primaryKeyMapFactory;
+    // per-segment
+    public final SegmentMetadata metadata;
+
+    private final IndexSegmentSearcher index;
+
+    public Segment(IndexContext indexContext, SSTableContext sstableContext, PerColumnIndexFiles indexFiles, SegmentMetadata metadata) throws IOException
+    {
+        this.minKey = metadata.minKey.token();
+        this.minKeyBound = minKey.minKeyBound();
+        this.maxKey = metadata.maxKey.token();
+        this.maxKeyBound = maxKey.maxKeyBound();
+
+        this.primaryKeyMapFactory = sstableContext.primaryKeyMapFactory;
+        this.metadata = metadata;
+
+        this.index = IndexSegmentSearcher.open(primaryKeyMapFactory, indexFiles, metadata, indexContext);
+    }
+
+    @VisibleForTesting
+    public Segment(Token minKey, Token maxKey)
+    {
+        this.primaryKeyMapFactory = null;
+        this.metadata = null;
+        this.minKey = minKey;
+        this.minKeyBound = minKey.minKeyBound();
+        this.maxKey = maxKey;
+        this.maxKeyBound = maxKey.maxKeyBound();
+        this.index = null;
+    }
+
+    /**
+     * @return true if current segment intersects with query key range
+     */
+    public boolean intersects(AbstractBounds<PartitionPosition> keyRange)
+    {
+        if (keyRange instanceof Range && ((Range<?>)keyRange).isWrapAround())
+            return keyRange.contains(minKeyBound) || keyRange.contains(maxKeyBound);
+
+        int cmp = keyRange.right.getToken().compareTo(minKey);
+        // if right is minimum, it means right is the max token and bigger than maxKey.
+        // if right bound is less than minKey, no intersection
+        if (!keyRange.right.isMinimum() && (!keyRange.inclusiveRight() && cmp == 0 || cmp < 0))
+            return false;
+
+        // if left bound is bigger than maxKey, no intersection
+        return keyRange.isStartInclusive() || keyRange.left.getToken().compareTo(maxKey) < 0;
+    }
+
+    public long indexFileCacheSize()
+    {
+        return index == null ? 0 : index.indexFileCacheSize();
+    }
+
+    /**
+     * Search on-disk index synchronously
+     *
+     * @param expression to filter on disk index
+     * @param context to track per sstable cache and per query metrics
+
+     * @return range iterator that matches given expression
+     */
+    public KeyRangeIterator search(Expression expression, QueryContext context) throws IOException
+    {
+        return index.search(expression, context);
+    }
+
+    @Override
+    public void close()
+    {
+        FileUtils.closeQuietly(index);
+    }
+
+    @Override
+    public String toString()
+    {
+        return String.format("Segment{metadata=%s}", metadata);
+    }
+}
diff --git a/src/java/org/apache/cassandra/index/sai/disk/v1/segment/SegmentBuilder.java b/src/java/org/apache/cassandra/index/sai/disk/v1/segment/SegmentBuilder.java
new file mode 100644
index 0000000000..dd8666213d
--- /dev/null
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/segment/SegmentBuilder.java
@@ -0,0 +1,260 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.index.sai.disk.v1.segment;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.concurrent.atomic.AtomicInteger;
+import javax.annotation.concurrent.NotThreadSafe;
+
+import com.google.common.annotations.VisibleForTesting;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.cassandra.db.marshal.AbstractType;
+import org.apache.cassandra.index.sai.IndexContext;
+import org.apache.cassandra.index.sai.memory.RAMStringIndexer;
+import org.apache.cassandra.index.sai.disk.format.IndexDescriptor;
+import org.apache.cassandra.index.sai.disk.v1.trie.LiteralIndexWriter;
+import org.apache.cassandra.index.sai.utils.NamedMemoryLimiter;
+import org.apache.cassandra.index.sai.utils.PrimaryKey;
+import org.apache.cassandra.index.sai.utils.TypeUtil;
+import org.apache.cassandra.utils.FastByteOperations;
+import org.apache.lucene.util.BytesRefBuilder;
+
+/**
+ * Creates an on-heap index data structure to be flushed to an SSTable index.
+ */
+@NotThreadSafe
+public abstract class SegmentBuilder
+{
+    private static final Logger logger = LoggerFactory.getLogger(SegmentBuilder.class);
+
+    // Served as safe net in case memory limit is not triggered or when merger merges small segments..
+    public static final long LAST_VALID_SEGMENT_ROW_ID = (Integer.MAX_VALUE / 2) - 1L;
+    private static long testLastValidSegmentRowId = -1;
+
+    /** The number of column indexes being built globally. (Starts at one to avoid divide by zero.) */
+    private static final AtomicInteger ACTIVE_BUILDER_COUNT = new AtomicInteger(0);
+
+    /** Minimum flush size, dynamically updated as segment builds are started and completed/aborted. */
+    private static volatile long minimumFlushBytes;
+    private final AbstractType<?> termComparator;
+    private final NamedMemoryLimiter limiter;
+    private final long lastValidSegmentRowID;
+    private boolean flushed = false;
+    private boolean active = true;
+    // segment metadata
+    private long minSSTableRowId = -1;
+    private long maxSSTableRowId = -1;
+    private long segmentRowIdOffset = 0;
+
+    // in token order
+    private PrimaryKey minKey;
+    private PrimaryKey maxKey;
+    // in termComparator order
+    private ByteBuffer minTerm;
+    private ByteBuffer maxTerm;
+
+    long totalBytesAllocated;
+    int rowCount = 0;
+    int maxSegmentRowId = -1;
+
+    public static class RAMStringSegmentBuilder extends SegmentBuilder
+    {
+        final RAMStringIndexer ramIndexer;
+
+        final BytesRefBuilder stringBuffer = new BytesRefBuilder();
+
+        public RAMStringSegmentBuilder(AbstractType<?> termComparator, NamedMemoryLimiter limiter)
+        {
+            super(termComparator, limiter);
+
+            ramIndexer = new RAMStringIndexer();
+            totalBytesAllocated = ramIndexer.estimatedBytesUsed();
+        }
+
+        public boolean isEmpty()
+        {
+            return ramIndexer.isEmpty();
+        }
+
+        protected long addInternal(ByteBuffer term, int segmentRowId)
+        {
+            copyBufferToBytesRef(term, stringBuffer);
+            return ramIndexer.add(stringBuffer.get(), segmentRowId);
+        }
+
+        @Override
+        protected SegmentMetadata.ComponentMetadataMap flushInternal(IndexDescriptor indexDescriptor, IndexContext indexContext) throws IOException
+        {
+            try (LiteralIndexWriter writer = new LiteralIndexWriter(indexDescriptor, indexContext))
+            {
+                return writer.writeCompleteSegment(ramIndexer.getTermsWithPostings());
+            }
+        }
+
+        private void copyBufferToBytesRef(ByteBuffer buffer, BytesRefBuilder stringBuffer)
+        {
+            int length = buffer.remaining();
+            stringBuffer.clear();
+            stringBuffer.grow(length);
+            FastByteOperations.copy(buffer, buffer.position(), stringBuffer.bytes(), 0, length);
+            stringBuffer.setLength(length);
+        }
+    }
+
+    public static int getActiveBuilderCount()
+    {
+        return ACTIVE_BUILDER_COUNT.get();
+    }
+
+    private SegmentBuilder(AbstractType<?> termComparator, NamedMemoryLimiter limiter)
+    {
+        this.termComparator = termComparator;
+        this.limiter = limiter;
+        this.lastValidSegmentRowID = testLastValidSegmentRowId >= 0 ? testLastValidSegmentRowId : LAST_VALID_SEGMENT_ROW_ID;
+
+        minimumFlushBytes = limiter.limitBytes() / ACTIVE_BUILDER_COUNT.incrementAndGet();
+    }
+
+    public SegmentMetadata flush(IndexDescriptor indexDescriptor, IndexContext indexContext) throws IOException
+    {
+        assert !flushed : "Cannot flush an already flushed segment";
+        flushed = true;
+
+        if (getRowCount() == 0)
+        {
+            logger.warn(indexContext.logMessage("No rows to index during flush of SSTable {}."), indexDescriptor.sstableDescriptor);
+            return null;
+        }
+
+        SegmentMetadata.ComponentMetadataMap indexMetas = flushInternal(indexDescriptor, indexContext);
+
+        return new SegmentMetadata(segmentRowIdOffset, rowCount, minSSTableRowId, maxSSTableRowId, minKey, maxKey, minTerm, maxTerm, indexMetas);
+    }
+
+    public long add(ByteBuffer term, PrimaryKey key, long sstableRowId)
+    {
+        assert !flushed : "Cannot add to a flushed segment.";
+        assert sstableRowId >= maxSSTableRowId;
+        minSSTableRowId = minSSTableRowId < 0 ? sstableRowId : minSSTableRowId;
+        maxSSTableRowId = sstableRowId;
+
+        assert maxKey == null || maxKey.compareTo(key) <= 0;
+        if (minKey == null)
+            minKey = key;
+        maxKey = key;
+
+        minTerm = TypeUtil.min(term, minTerm, termComparator);
+        maxTerm = TypeUtil.max(term, maxTerm, termComparator);
+
+        if (rowCount == 0)
+        {
+            // use first global rowId in the segment as segment rowId offset
+            segmentRowIdOffset = sstableRowId;
+        }
+
+        rowCount++;
+
+        // segmentRowIdOffset should encode sstableRowId into Integer
+        int segmentRowId = castToSegmentRowId(sstableRowId, segmentRowIdOffset);
+        maxSegmentRowId = Math.max(maxSegmentRowId, segmentRowId);
+
+        long bytesAllocated = addInternal(term, segmentRowId);
+        totalBytesAllocated += bytesAllocated;
... 18343 lines suppressed ...


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@cassandra.apache.org
For additional commands, e-mail: commits-help@cassandra.apache.org