[WIP] RFC: Index Build 4.x #12465
Closed
Astronomax
started this conversation in
RFC
Replies: 2 comments 2 replies
|
Эта штука как-то адресует проблему блокирующего создания индексов в синхронной репликации? |
2 replies
|
The discussion has been moved to Confluence. |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Reviewers
Problem
Memtx doesn't wait for prepared transactions to become committed on yielding DDL
https://github.com//issues/10930In Vinyl,
journal_sync()is called before starting to build a new index so that the code waits forCONFIRMon allpreparedtransactions. This does not happen in Memtx, so the main build loop may insert aprepared(but not yetconfirmed) tuple that, for example, had already been stuck in the WAL queue before the build started, and that tuple will not be removed from the index if the transaction is rolled back due to a WAL failure. In other words, ideally, the only things that should be able to roll back changes in the new index are parallel transactions that appeared after index build had already started.In fact,
journal_sync()alone would not be enough here, because there are several more places where a transaction may get stuck: the queue before WAL,limbo, the queue beforelimbo, and possibly something else as well.Memtx doesn't handle rollback of concurrent writers on yielding DDL
https://github.com//issues/10931This problem has the same consequence as the previous one: after a transaction rolls back, the tuple remains in the index. Unlike the previous issue, here the problem is related to tuples that appeared during index build and ended up to the right of the cursor. No rollback trigger is installed for such tuples, so nothing will remove them from the building index if the corresponding transactions are rolled back.
One possible solution would be to always install a rollback trigger regardless of which side of the cursor the tuple is on when
on_replaceis called, and add logic to that trigger that checks whether the cursor has already passed this tuple by the time rollback happens. If it has, the trigger would perform the corresponding rollback in the building index.Vinyl doesn't wait for writers to be confirmed by replicas on yielding DDL
https://github.com//issues/10929This problem was already mentioned above: Memtx does not even have
journal_sync()before index build starts, while Vinyl does, but it turns out to be insufficient, because WAL is not the only queue where a transaction may be stuck before it becomesconfirmed.memtx: crash when building index with mvcc
https://github.com//issues/12261The problem is that
memtx_tx_invalidate_spaceessentially makes an invalid assumption that it is safe to simply remove all extra information related topreparedtransactions from MVCC state, in particularmemtx_story.At the moment,
memtx_tx_invalidate_spacedoes the following:memtx_story) associated with a given spacein-progresstransactionsprepared/confirmedtransactions.This is clearly unsafe for many reasons.
For example, consider the following situation:
preparedtransactiontxnis sitting in the queue waiting to be written.memtx_tx_invalidate_space). It does not even have to commit or enter the WAL queue:box.begin() DDL() box.rollback()is already enough.tupleof prepared transactiontxn. However, the correspondingstorywas deleted, and together with it the connection between the tuple and the corresponding transaction statementstmtwas lost.in-progresstransactions then arrive and executereplacewith a key from tupletuple(primary or secondary, it does not matter). A new storystory'is created for tupletuple, but this time it no longer contains a reference to statementstmt. At the same time, all these replaces are effectively applied to the index immediately in order to preserve the invariant that the top of the chain is physically present in the index:tarantool/src/box/memtx_tx.c
Lines 2972 to 2976 in 1e3c241
story'.txnis rolled back. The rollback goes throughrollback_empty_stmt(because the reference tostmtis missing), which blindly replacesrollback_info->new_tuplewithrollback_info->old_tuplein the index.The issue contains a slightly different example. Instead of building new chains from
in-progressstories, it starts building a new index. Surprisingly, even in that case there are still certain problems. The rollback of apreparedstory is not noticed by anyone, again because aftermemtx_tx_invalidate_spacethat story is no longer linked to itsadd_stmtstatement. As a result, the tuple manages to be inserted into the building secondary index, and then on rollback it is removed only from the primary index. Even though the corresponding read tracker was added to the story, secondary index build does not abort, again because thestmt->storyconnection is missing.Only
confirmedstories can be detached fromadd_stmtcompletely safely and without any extra logic.One possible solution to this problem would be to abort all related
in-progresstransactions first when such apreparedstatement is rolled back, for example directly fromrollback_empty_stmt.vinyl: building a unique index can end up with duplicates in it
https://github.com//issues/12284The problem is that the index build procedure in Vinyl misses certain scenarios in which duplicates may appear in the building unique index. Uniqueness is checked in two places:
confirmedtupletuplethat existed before the build (vy_stmt_lsn(tuple) <= build_lsn)on_replacetrigger when handling parallelin-progresstransactionsIn both cases the check is implemented in the same way:
vy_getin the building index by the corresponding key of tupletuple, then compare the primary key oftuplewith whatever was returned byvy_get. In the first case the isolation isp_committed_read_view(i.e.READ_CONFIRMED), in the second case it isvy_tx_read_view(tx)(i.e. according to the isolation level of thein-progresstransaction for whichon_replacefired). Both choices look suspicious.The reproducer from the issue does the following:
{1, 1}and yields.in-progresstransactiontxnexecutesreplace{3, 2}, checks for duplicates, and finds nothing for key2.txncommits, becomesprepared, and gets stuck in WAL.{2, 2}. It performsvy_getinp_committed_read_viewand again finds nothing for key2, because{3, 2}has not yet been written to WAL.txnand the build are successfully written to WAL.It seems that simply changing
p_committed_read_viewtop_global_read_viewwhen inserting in the build loop would be enough to fix this specific class of scenarios. However, it might also make sense to usep_committed_read_viewin theon_replacecheck for parallelin-progresstransactions as well. I have not verified this, but it is likely possible to reproduce the problem if two duplicates are inserted one after another in parallel with index build while WAL is slowed down. All other situations are handled, among other things, thanks to two facts:in-progresstransactions on that space.in-progresstransaction becomesprepared, Vinyl iterates over itswrite_setand aborts allin-progressinserts with the same key.Allow to write to space on background index build with MVCC enabled
https://github.com//issues/10170If we omit unimportant details, Memtx index build with MVCC currently looks like this:
index_countis called to check that the index is not empty.index_countleaves certain trackers (gap_item_type = GAP_COUNT) to guarantee the correctness of this read.preparedtransactions and tracks all keys it has read, as well as the intervals between them.replacein the space and commits, index build aborts. It seems that it does not even matter which side of the cursor that replace happens on.Thus, any small parallel write operation aborts a long-running index build. What we want is a new index build procedure with MVCC that allows parallel writes and handles them correctly so that index build aborts as rarely as possible.
Requirements
fiber()->txn == NULL) so that it can later be used in the context of non-blocking build: https://github.com/orgs/tarantool/discussions/11018.Possible Solution №1 (Anton Kuznets @Astronomax)
This solution even has an implementation that passes the tests and issue reproducers. It is worth noting, though, that the current tests do not cover index build very well.
Unfortunately, with this approach it was not possible to avoid engine-specific details in a completely clean way; we will discuss why below.
Therefore, the procedure takes an engine-provided
vtabwith the following methods:replace_confirmed(index, tuple)replace_in_progress(index, old_tuple, new_tuple)rollback(index, old_tuple, new_tuple)finalize(index)We will see below what they are needed for and how they are implemented in Memtx and Vinyl.
In general, the proposed procedure is similar to what currently works in Memtx:
on_replaceto handle parallelreplaceoperations. The main reason it is needed is duplicate lookup in a Vinyl index. We will discuss later what exactly it does and why it is needed.CONFIRMon allpreparedtransactions stuck in various queues:txn_limbo_flush,journal_sync. Roughly the same as here:tarantool/src/box/checkpoint.c
Lines 69 to 74 in 9ec082b
ITER_ALL) and, in a loop (which we will call the "build-loop" below), insert tuples into the index while periodically yielding. Since there is no active transaction attached to the fiber at this moment, the iterator leaves no trackers or anything like that. In both Memtx and Vinyl the iterator readspreparedtuples. In fact, as we will see later, even withread-committed(read-prepared) isolation the loop will skippreparedtuples, so correctness would not break even if the iterator itself only readconfirmedtuples.vtab->finalizeis performed. It is needed only for Vinyl for now; we will discuss what it is later.on_replacetrigger.in-progresstransactions on that space. Important: there must be noyieldbetween this invalidation and the previous step.The following structures are maintained during the process:
processed: the set of keys that build-loop must skip because their consistency is guaranteed by statement-level triggers.Suppose a parallel
replace{tuple}happens while build-loop is running, such thattupleends up to the right of the current cursor position. Such tuples could be deferred and made the responsibility of build-loop, which is how it is implemented now.write_set(unique indexes only): something similar to Vinyl'swrite_set, a mappingbuild_key -> txn set. It can be viewed as the set of "write points" of parallel transactions. A "write point" is a pair(key, txn)created at the moment a parallel transactiontxninsertstuple, such thatbuild_key(tuple) = key. It is used to abort conflictingin-progresswriters and thus avoid duplicates.not_confirmed(unique indexes only):key -> int. Its purpose will be discussed later.on_replace:new_tupleagainst the new index format.result = building_index.get(build_key(new_tuple)), then compareresult == old_tuple. If some tupleresultdifferent fromold_tupleis found, that is a duplicate andnew_tuplemust not be inserted. In this case thereplacein the transaction fails with an error, while build-loop keeps running.(build_key(new_tuple), txn)towrite_set.before_commit,on_commit, andon_rollbacktriggers on the statement.before_commit(on_prepare):pk = primary_key(new_tuple ?: old_tuple).old_tupleis already present in the index in order to callvtab->replace_in_progresscorrectly.old_tupleis already in the index if:pkprocessed.pkis to the left of or equal to the build cursor, i.e. build-loop has already passedpk.not_confirmed[build_key(old_tuple)]++. We will discuss the purpose ofnot_confirmedlater, so for now just skip it.vtab->replace_in_progress(old_tuple or NULL, new_tuple).pkwas to the right of the cursor, addpktoprocessed. Now build-loop will skip this PK and will not duplicate the already applied change.write_setbecause the transaction is no longerin-progress.in-progresswriters fromwrite_setfor keybuild_key(new_tuple).on_rollback:in-progress(i.e.box.commitwas never called):write_set.preparehad already happened by that time:on_prepare.old_tupleagainst the new index format. This is needed becauseold_tuplemay have been committed before the build started and therefore might never have been validated before.in-progresswriters fromwrite_setfor keybuild_key(old_tuple).not_confirmed[build_key(old_tuple)]--. We will discuss the purpose ofnot_confirmedlater, so for now just skip it.vtab->rollback(old_tuple, new_tuple). This rolls back changes previously made byreplace_in_progress: removenew_tuple, restoreold_tuple. We will discuss later what exactly this means for each engine.on_commit(on_confirm):build-loop:
tuple, remove fromprocessedall keys that are strictly to the left of the currenttuple.tupleis found inprocessed, build-loop skips it entirely: this PK has already been handled viaon_prepare/on_rollback.tupleagainst the new index format.not_confirmed[build_key(tuple)] == 0. If it is> 0, abort the build. We are not discussing why just yet.in-progresswriters fromwrite_setfor keybuild_key(tuple).vtab->replace_confirmed(tuple).sleep(0)on everyNth iteration.Note
It is important to keep in mind that some triggers may yield. While the trigger fiber is suspended, build-loop may manage to finish or abort and free resources that the trigger still needs. This must be taken into account in the implementation.
What is
not_confirmed, and what problem does it solve?At this point it may seem that everything described above is enough to ensure correct behavior across the full set of possible scenarios. However, there is still at least one more problem:
Consider the following situation:
{1, 1},{2, 2},{3, 2}{1, 1}replace{3, 3}happens inREPLACE_TXN, goes to WAL, and gets stuck there{2, 2}, and goes to sleep again. Assuming duplicate checks read withread-committed(read-prepared) isolation, duplicate{3, 2}is not detected ({3, 3}is alreadypreparedat this point).REPLACE_TXNrolls back to{3, 2}. At this point we need to check duplicates in order to detect duplicate pair{2, 2},{3, 2}. The most obvious thing would be to performgetby key2in the index. However, in Vinyl this is a problem becausegetyields, which is not allowed during rollback.On closer inspection, it turns out that such "conflicts" (duplicates) arise specifically between an insertion from the main loop and a parallel insertion from a transaction. Two insertions from the main loop, and even, less obviously, parallel inserts from transactions, do not create this problem between themselves. We will discuss why below.
Several possible solutions to this problem:
BUILD_TXN. The transaction must also write something in order to pass through WAL, for example some progress statistic in_index_local, so that this does not look too hacky. Then the scenario above works correctly becauseBUILD_TXNis rolled back transitively together withREPLACE_TXN. But what ifREPLACE_TXNrolls back beforeBUILD_TXNcallsbox.commit(becomesprepared)? Fortunately, the procedure also haswrite_setspecifically for such situations, soREPLACE_TXNwill abortBUILD_TXN. This option will appear again later in possible solution №2.{2, 2}from the main loop, check duplicates twice: once withread-committedisolation, once withread-confirmed. Inon_replace, one check is enough. That means that if duplicates existed before the build started, the build may complete successfully only if all of them are somehow replaced before the build cursor reaches them.not_confirmed. Incrementnot_confirmed[build_key(old_tuple)]++when a transaction transitions fromin-progresstoprepared, and decrement it when the transaction transitions frompreparedtoconfirmed/rolled back. When inserting from build-loop, checknot_confirmed[build_key(tuple)] == 0. In the scenario above, whenreplace{3, 3}is prepared, it performsnot_confirmed[2]++. After that, build-loop attempts to insert{2, 2}, but becausenot_confirmed[2] > 0, it aborts the build.The key difference between the Memtx and Vinyl models
Before considering
vtabfor both engines, it is worth explicitly stating one important difference between Memtx and Vinyl.In Memtx, an index change is essentially an immediate modification of the corresponding tree. If
new_tuplewas inserted into the building index fromon_prepare, then on rollback it must be explicitly removed from that tree and, if necessary,old_tuplemust be restored. Therefore, in Memtxrollbackisindex_replacein the reverse direction relative toreplace_in_progress.In Vinyl the model is different. There, a change does not rewrite the index in place, but instead appends statements (
INSERT/REPLACE/DELETE/UPSERT) to a sequence of statements that first live invy_txand itswrite_set, and then onprepareare flushed into the correspondingLSM'svy_memviavy_lsm_set(). Onconfirmthey are confirmed viavy_lsm_commit_stmt(), and when a prepared transaction is rolled back, the engine itself removes them fromvy_memviavy_lsm_rollback_stmt(). In other words, in Vinyl rollback of a prepared writer is handled entirely by the engine itself, not externally.Therefore,
replace_in_progressfor Vinyl and Memtx looks different in implementation:vy_txfor the buildingLSM: a surrogateDELETEforold_tupleand anINSERTfornew_tuple. After that, their lifecycle is managed by Vinyl itself.As a result, in Vinyl
rollbackinvtabcan beNoop, because the actual rollback is done not byindex_build, but byvy_tx_rollback()/vy_lsm_rollback_stmt().In this form,
vtablooks as follows:replace_confirmed(index, tuple): insert aconfirmedtupletuplefrom build-loop.Memtx:index_replace(index, NULL, tuple, DUP_INSERT).Vinyl: direct insertion of a statement into the buildingLSM(vy_build_insert_tuple()).replace_in_progress(index, old_tuple, new_tuple): handle a parallel transaction, called fromon_prepare.Memtx:index_replace(index, old_tuple, new_tuple, DUP_REPLACE_OR_INSERT).Vinyl: write corresponding statements intovy_txfor the building index:DELETE(old_tuple)and/orINSERT(new_tuple)viavy_tx_set(). These statements will then be written tovy_memonprepare, and later either confirmed or rolled back by Vinyl itself.rollback(index, old_tuple, new_tuple): roll back a parallelpreparedtransaction.Memtx: an explicit reverse operation, essentiallyindex_replace(index, new_tuple, old_tuple, ...).Vinyl:Noop, because rollback of prepared statements is already performed by the engine duringvy_tx_rollback().finalize(index): finalization needed only for Vinyl.Memtx:Noop.Vinyl: trigger dump of the newLSMso that the built index does not have to be rebuilt during recovery.Possible Solution №2 (Andrey Saranchin @drewdzzz)
Base part
Let us start with the core framework, i.e. with the parts that we definitely would like to include in this solution, and then consider additional options along the way.
First, build-loop now wraps insertion of an entire batch of tuples into transaction
BUILD_TXN.Roughly speaking, this transaction could look as follows:
box.begin()tuplefrom iteratorpk_iter. Memtx/Vinyl MVCC will track what was read at this point.res = index.get(build_key(tuple))<------------- ???res == NULL(build_key(tuple), BUILD_TXN)towrite_setindex_replace. It is non-transactional, i.e. it does not become a statement of the transaction. In principle this would look odd and it is not even very clear what it would mean for it to be transactional. Here the insertion happens into only one index, so rollback, if needed, must also be done only in that one index, whereas aREPLACEstatement means an atomic insertion into the space as a whole, i.e. into all its indexes. And if this is not a statement, then it is unclear how to roll such an insertion back ifBUILD_TXNitself rolls back, for example due to a conflict with a parallel transaction. Most likely, this would require additional machinery in build-loop.pk_iter.next()._index_local.box.commit()Warning
While writing the paragraph about
index_get(build_key(tuple)), I noticed one more important thing. In the implementation of solution №1,index_get_internalis used. In Memtx,index_get_internalis implemented in such a way (memtx_tree_index_get_internal) that when MVCC is enabled it additionally searches the chain for the version visible to the reading transaction (memtx_tx_tuple_clarify). The problem is that Memtx MVCC does not handle building indexes, i.e. it does not maintain version chains or conflict resolution for them. Ifindex_get_internalis called on a building Memtx index, Memtx MVCC will attempt to iterate the history chain. At that point it will accessstory->link[index->dense_id], which does not exist (linkwas allocated under the assumption that the index with thatdense_iddoes not yet exist), which leads to a segfault. In proposed solution №1 this does not happen, but only by accident. There allindex_get_internalreads are performed withread-prepared(is_prepared_ok = true) isolation, and all parallel inserts happen specifically inon_prepare(before_commit). That meansindex_get_internalis always called at a moment when the index contains a visible prepared tuple, so the loop always terminates on the very first iteration without ever accessinglink[index->dense_id]. In both solution №1 and solution №2 what we really need is just a rawgetin the data structure, without any history traversal, so most likely something inengine_vtabwould have to change so that it exposes a rawgetthere (just a wrapper overmemtx_tree_find). But for Vinyl this would look strange, because everything works very differently there. It is not really obvious what a "raw"getshould mean for Vinyl. In a sense, Vinyl does track history even for the building index. Vinyl has its own internal statements for each index separately. As soon as a parallel transaction adds a Vinyl statement (building_index:REPLACE()), the change enters history. For Vinyl, thisgetcould be defined asgetwithread_preparedisolation, i.e. "get the last tuple in index history".There is also a question about the iterator. In the first solution, there was one iterator for the whole build-loop without a transaction, so it tracked nothing. Here we need transactional iterators, one per
BUILD_TXN. But then we somehow need to remember the position where the iterator of the previousBUILD_TXNstopped, so that the next one can continue from there. And this must remain correct in the presence of parallel inserts. It is not yet clear to me whether this can be done and how?on_replacefor parallel transactions:(build_key(tuple), BUILD_TXN)towrite_set.index_replace.on_preparefor parallel transactionsREPLACE_TXNand transactionBUILD_TXN:on_prepare(before_commit) on each individual statement. In the case ofBUILD_TXN, since inserts do not create statements, it might be possible to do it via a txn-levelbefore_committrigger, which currently does not exist, just like the statement-level one.in-progressduplicates viawrite_set.One drawback of solution №1 is that a parallel insertion to the right of the cursor leaves a flag in the
processedmap, and that flag will only be removed later when the cursor crosses the corresponding primary key. In other words, the flag appears inon_prepareand is not removed later in eitheron_confirmoron_rollback. Thus, in the worst case this isO(number of transactions that happened in parallel with the build)additional memory. Ideally we would like to limit this to at leastO(max number of simultaneously active in-progress/prepared transactions). If this turns out to matter, it can be added to the requirements above. It is not even entirely clear why this matters, since a proportional amount of memory will anyway be allocated for the corresponding tuples.Therefore, here we would like to return to the old approach and insert from triggers only what ends up to the left of the cursor, thus avoiding additional marks in the form of
processed.Overall, everything described above is just a rough outline of what would likely be worth fixing in this solution №2. We will now select the remaining pieces.
More variable part
Parallel insertion into a build-loop batch raises many questions.
Suppose the next batch starts from
build-cursor. Let us call the cursor position at some point during batch iterationbuild-cursor'. Sinceindex.getmay yield in Vinyl, it makes sense to keep track ofbuild-cursor'.build-cursor''is the cursor position at the end of the current batch (and the start of the next batch). At the start of each new build-loop transaction onlybuild-cursorandbuild-cursor'are known.Parallel insertion (
on_replace) to the left ofbuild-cursorThe simplest case is insertion to the left of
build-cursorby a parallel transaction. In that caseon_replaceimmediately inserts the tuple into the index. It is obvious that rolling such a tuple back must be the responsibility of theon_rollbacktrigger.Parallel insertion (
on_replace) betweenbuild-cursorandbuild-cursor'Now suppose a parallel insertion happens somewhere between
build-cursorandbuild-cursor'.In this case
on_replacecould, for example:index_replaceindex_replace, and insteadBUILD_TXNand restart the transaction for this batch so thatbuild-cursor'traverses this range again and picks up the tuple on its ownREPLACE_TXN(REPLACE_TXNaborts itself)BUILD_TXNwill later process somehowRegardless of what
on_replacedoes in this situation, there are 4 possible variants of the order in whichBUILD_TXNandREPLACE_TXNchange their states.Suppose
on_replacedirectly callsindex_replacein such cases. Then:BUILD_TXNat the momentREPLACE_TXNis prepared. After thatBUILD_TXNrestarts, and we end up in the situation "parallelon_replacebetweenbuild-cursor'andbuild-cursor''calledindex_replace".BUILD_TXNwould then somehow need to understand that the tuple has already been inserted byon_replaceand must be skipped.BUILD_TXNaborts, thenREPLACE_TXNis also rolled back transitively. And ifBUILD_TXNsuccessfully becomesconfirmed, then what happens toREPLACE_TXNafterwards does not matter much, as long as it restores the correct tuple in case it is eventually rolled back.BUILD_TXNsuccessfully becomesconfirmed, then this becomes the simple case "on_replaceto the left ofbuild-cursor". IfBUILD_TXNaborts, then after restart we again get the situation "parallelon_replacebetweenbuild-cursor'andbuild-cursor''calledindex_replace", which we will discuss next.So in the end all the complexity is reduced to parallel
on_replacebetweenbuild-cursor'andbuild-cursor''that calledindex_replace. The key is to solve that case.Suppose
on_replacedoes not callindex_replacein such cases, abortsBUILD_TXN, but does not abort itself.This is even simpler: all variants are immediately reduced to the case parallel
on_replaceto the right ofbuild-cursor'did not callindex_replace(the transaction restarts andbuild-cursor'ends up to the left of the insertion). Again, one would need some way to make sureBUILD_TXNdoes not insert this tuple a second time.on_replacedoes not callindex_replaceand abortsREPLACE_TXN(itself).This is clearly fine. It remains only to consider parallel insertion to the right of
build-cursor'.on_replacedoes not callindex_replace, but leaves the tuple in some list.What could happen with this list in principle? In a private discussion with Andrey, I think we considered the option where
BUILD_TXNwould process this list before handling the next batch.This option has a lot of complications:
BUILD_TXNwould have to inspect this list before each next batch in primary key order, determine which entries are already to the left ofbuild-cursorand which are still to the right, and callindex_replacefor some of them.in-progress,prepared,confirmed,rolled back. Since reading the list is not tracked in any way and MVCC knows nothing about it, there are problems related to the fact that one of the tuples in the list may be rolled back. In particular, it may roll backin-progress -> rolled back, whileBUILD_TXNis alreadypreparedand sitting in WAL. In that case it becomes completely unclear how that insertion should be rolled back. Most likely, the tuple should not be added to the list immediately; at the very leaston_prepareshould be installed and the tuple should be added there.In principle this is probably implementable, but it looks unnecessarily complex.
Parallel
on_replacebetweenbuild-cursor'andbuild-cursor''calledindex_replaceNow consider a parallel insertion to the right of
build-cursor'. Recall that this is exactly the case to which all the complexity was reduced in the paragraph abouton_replacebetweenbuild-cursorandbuild-cursor'that immediately callsindex_replace.The main problem here is that as soon as
on_replaceto the right ofbuild-cursor'callsindex_replaceitself,BUILD_TXNthen has to somehow learn that this tuple has already been applied to the index externally. IfBUILD_TXNlater reaches this tuple and knows nothing about it, it will try to insert it a second time. We again come back to the need to keep some analogue ofprocessed, and everything becomes even more complicated.Parallel
on_replaceto the right ofbuild-cursor'did not callindex_replaceConsider the following situation:
on_replace(old_tuple, new_tuple)happens to the right ofbuild-cursor'on_replaceonly checks duplicateswrite_setREPLACE_TXNthen gets stuck inin-progressfor a long time.build-cursor'passes throughnew_tuple. Since the tuple isin-progress,BUILD_TXNwill not read it. But it will definitely readold_tuple, because that one is at leastprepared, and will insert it into the building index.BUILD_TXNsuccessfully commits and becomes confirmed.BUILD_TXNends up somewhere to the right, but the tuple was inserted neither fromon_replacenor fromBUILD_TXN.REPLACE_TXNbecomes prepared:REPLACE_TXNcan now look atbuild-cursorandbuild-cursor'to decide what to do.build-cursorat that moment will already be somewhere to the right (for example, if we movebuild-cursoronconfirmofBUILD_TXN). ThenREPLACE_TXNcould do one of the following:index_replaceitself onprepareofREPLACE_TXN. Callingindex_replaceonpreparelooks strange, but perhaps it would be possible.on_confirm(on_commit) trigger that would put this tuple into some list from whichBUILD_TXNwould later fetch it. But then one has to look carefully at the situation where build-loop has already finished whilepreparedREPLACE_TXNwas still stuck in WAL. If after that it gets confirmed and puts the tuple into the list, build-loop will never look at that list again.Rollbacks seem somewhat better for this approach.
Consider the following situation:
on_replace(old_tuple, new_tuple)happens to the right ofbuild-cursor'on_replaceonly checks duplicateswrite_setbox.commitis called onREPLACE_TXN; the transaction becomespreparedand gets stuck in WAL.build-cursor'passes throughnew_tuple. Since the tuple isprepared,BUILD_TXNreads it and inserts it into the building index.BUILD_TXNgets prepared, everything is fine: even ifREPLACE_TXNis rolled back on a WAL error,BUILD_TXNwill also be rolled back together with it.REPLACE_TXNrolls back beforeprepareofBUILD_TXN, thenREPLACE_TXNshould look atbuild-cursorandbuild-cursor': if the tuple is between them, thenBUILD_TXNshould probably be rolled back. In any case, it will be rolled back automatically because the iterator in build-loop tracked this key while passing through it.Final variant for solution №2
If we choose the most acceptable options from all the variants above, the algorithm becomes the following:
BUILD_TXNreads the primary in batches withread-preparedisolation and maintains two cursors:build-cursor: the last position that is guaranteed to be reflected in the building index and completed by previous batches.build-cursor': the current position inside the active batch.BUILD_TXNdoes not callindex_replaceimmediately. It only accumulates apending batch. The batch is physically applied to the building index onprepareofBUILD_TXNvia a separate txn-level trigger. This is fundamentally important, otherwise it would be hard to roll the batch back correctly on an MVCC conflict or WAL error.on_replaceto the left ofbuild-cursorimmediately callsindex_replace. This is the simplest case: build-loop will never return there again, and rollback/confirm can be handled by triggers.on_replacebetweenbuild-cursorandbuild-cursor'does not callindex_replace, but aborts the currentBUILD_TXN. After restart, the batch reads that range again, now taking the new version into account, and inserts the correct tuple by itself.on_replaceto the right ofbuild-cursor'does not callindex_replace, but only:write_set;REPLACE_TXN.prepare,REPLACE_TXNcompares its primary key with the currentbuild-cursor:build-cursor, then build-loop has already passed that key, and nowREPLACE_TXNitself must apply its change to the building index via something likereplace_in_progressfrom solution №1;build-cursor, nothing needs to be done: a future batch will itself see the alreadyprepared/confirmedstate and insert the correct tuple.rollback,REPLACE_TXNacts symmetrically:on_preparewas actually executed for this statement. Trigger state needs a separate flag like "the change was applied fromon_prepare".on_preparewas not executed or returned before callingreplace_in_progress, there is nothing to roll back.on_rollbackcompares the same primary keypk = primary_key(new_tuple ?: old_tuple)with the currentbuild-cursor:pkis to the left of or equal tobuild-cursor, then the change should already have been reflected in the building index, and it must be rolled back viarollback(old_tuple, new_tuple);pkis to the right ofbuild-cursor, then build-loop either has not yet passed this key, or, if it has, it will be rolled back thanks to MVCC. Nothing needs to be done.Warning
However, this approach still has at least one unresolved problem, which makes it look questionable.
Suppose
BUILD_TXNread sometuplefrom primary and included it into itspending batch. It does not matter whether thistuplewaspreparedor alreadyconfirmedat read time. What matters is that onprepare,BUILD_TXNwants to physically apply the corresponding insertion to the building index. But ifBUILD_TXNitself later rolls back, these tuples must then be rolled back from the new index.For
Vinyl, this is a problem: an insertedconfirmedtuple may manage not only to get intovy_mem, but also to be dumped. After that, rollback ofBUILD_TXNno longer looks like a normal transaction rollback. It is no longer rollback of a prepared statement performed by the engine.All reactions