Add the fts5 'delete-automerge' integer option. A level is eligible for auto-merging if it has a greater than or equal percentage of its entries deleted by tombstones than the 'delete-automerge' option. Default value is 10.

FossilOrigin-Name: b314be66b9ac0190b5373b3b6baec012382bc588c2d86c2edab796669a4303c3
This commit is contained in:
dan 2023-07-24 19:13:06 +00:00
parent 2159292ce0
commit 24730de8d1
8 changed files with 159 additions and 69 deletions

View File

@ -214,6 +214,7 @@ struct Fts5Config {
char *zRank; /* Name of rank function */
char *zRankArgs; /* Arguments to rank function */
int bSecureDelete; /* 'secure-delete' */
int nDeleteAutomerge; /* 'delete-automerge' */
/* If non-NULL, points to sqlite3_vtab.base.zErrmsg. Often NULL. */
char **pzErrmsg;

View File

@ -22,6 +22,8 @@
#define FTS5_DEFAULT_CRISISMERGE 16
#define FTS5_DEFAULT_HASHSIZE (1024*1024)
#define FTS5_DEFAULT_DELETE_AUTOMERGE 10 /* default 10% */
/* Maximum allowed page size */
#define FTS5_MAX_PAGE_SIZE (64*1024)
@ -922,6 +924,18 @@ int sqlite3Fts5ConfigSetValue(
}
}
else if( 0==sqlite3_stricmp(zKey, "delete-automerge") ){
int nVal = -1;
if( SQLITE_INTEGER==sqlite3_value_numeric_type(pVal) ){
nVal = sqlite3_value_int(pVal);
}else{
*pbBadkey = 1;
}
if( nVal<0 ) nVal = FTS5_DEFAULT_DELETE_AUTOMERGE;
if( nVal>100 ) nVal = 0;
pConfig->nDeleteAutomerge = nVal;
}
else if( 0==sqlite3_stricmp(zKey, "rank") ){
const char *zIn = (const char*)sqlite3_value_text(pVal);
char *zRank;
@ -970,6 +984,7 @@ int sqlite3Fts5ConfigLoad(Fts5Config *pConfig, int iCookie){
pConfig->nUsermerge = FTS5_DEFAULT_USERMERGE;
pConfig->nCrisisMerge = FTS5_DEFAULT_CRISISMERGE;
pConfig->nHashSize = FTS5_DEFAULT_HASHSIZE;
pConfig->nDeleteAutomerge = FTS5_DEFAULT_DELETE_AUTOMERGE;
zSql = sqlite3Fts5Mprintf(&rc, zSelect, pConfig->zDb, pConfig->zName);
if( zSql ){

View File

@ -475,7 +475,6 @@ static int fts5HashEntrySort(
pList = fts5HashEntryMerge(pList, ap[i]);
}
pHash->nEntry = 0;
sqlite3_free(ap);
*ppSorted = pList;
return SQLITE_OK;
@ -529,10 +528,25 @@ int sqlite3Fts5HashScanInit(
return fts5HashEntrySort(p, pTerm, nTerm, &p->pScan);
}
#ifdef SQLITE_DEBUG
static int fts5HashCount(Fts5Hash *pHash){
int nEntry = 0;
int ii;
for(ii=0; ii<pHash->nSlot; ii++){
Fts5HashEntry *p = 0;
for(p=pHash->aSlot[ii]; p; p=p->pHashNext){
nEntry++;
}
}
return nEntry;
}
#endif
/*
** Return true if the hash table is empty, false otherwise.
*/
int sqlite3Fts5HashIsEmpty(Fts5Hash *pHash){
assert( pHash->nEntry==fts5HashCount(pHash) );
return pHash->nEntry==0;
}

View File

@ -56,8 +56,6 @@
#define FTS5_MAX_LEVEL 64
#define FTS5_MERGE_TOMBSTONE_WEIGHT 5
/*
** There are two versions of the format used for the structure record:
**
@ -355,6 +353,7 @@ struct Fts5Index {
i64 iWriteRowid; /* Rowid for current doc being written */
int bDelete; /* Current write is a delete */
int nContentlessDelete; /* Number of contentless delete ops */
int nPendingRow; /* Number of INSERT in hash table */
/* Error state. */
int rc; /* Current error code */
@ -404,6 +403,8 @@ struct Fts5StructureSegment {
u64 iOrigin1;
u64 iOrigin2;
int nPgTombstone; /* Number of tombstone hash table pages */
i64 nEntryTombstone; /* Number of tombstone entries that "count" */
i64 nEntry; /* Number of rows in this segment */
};
struct Fts5StructureLevel {
int nMerge; /* Number of segments in incr-merge */
@ -1117,6 +1118,8 @@ static int fts5StructureDecode(
i += fts5GetVarint(&pData[i], &pSeg->iOrigin1);
i += fts5GetVarint(&pData[i], &pSeg->iOrigin2);
i += fts5GetVarint32(&pData[i], pSeg->nPgTombstone);
i += fts5GetVarint(&pData[i], &pSeg->nEntryTombstone);
i += fts5GetVarint(&pData[i], &pSeg->nEntry);
nOriginCntr = MAX(nOriginCntr, pSeg->iOrigin2);
}
if( pSeg->pgnoLast<pSeg->pgnoFirst ){
@ -1372,13 +1375,16 @@ static void fts5StructureWrite(Fts5Index *p, Fts5Structure *pStruct){
assert( pLvl->nMerge<=pLvl->nSeg );
for(iSeg=0; iSeg<pLvl->nSeg; iSeg++){
fts5BufferAppendVarint(&p->rc, &buf, pLvl->aSeg[iSeg].iSegid);
fts5BufferAppendVarint(&p->rc, &buf, pLvl->aSeg[iSeg].pgnoFirst);
fts5BufferAppendVarint(&p->rc, &buf, pLvl->aSeg[iSeg].pgnoLast);
Fts5StructureSegment *pSeg = &pLvl->aSeg[iSeg];
fts5BufferAppendVarint(&p->rc, &buf, pSeg->iSegid);
fts5BufferAppendVarint(&p->rc, &buf, pSeg->pgnoFirst);
fts5BufferAppendVarint(&p->rc, &buf, pSeg->pgnoLast);
if( pStruct->nOriginCntr>0 ){
fts5BufferAppendVarint(&p->rc, &buf, pLvl->aSeg[iSeg].iOrigin1);
fts5BufferAppendVarint(&p->rc, &buf, pLvl->aSeg[iSeg].iOrigin2);
fts5BufferAppendVarint(&p->rc, &buf, pLvl->aSeg[iSeg].nPgTombstone);
fts5BufferAppendVarint(&p->rc, &buf, pSeg->iOrigin1);
fts5BufferAppendVarint(&p->rc, &buf, pSeg->iOrigin2);
fts5BufferAppendVarint(&p->rc, &buf, pSeg->nPgTombstone);
fts5BufferAppendVarint(&p->rc, &buf, pSeg->nEntryTombstone);
fts5BufferAppendVarint(&p->rc, &buf, pSeg->nEntry);
}
}
}
@ -3988,6 +3994,7 @@ static void fts5IndexDiscardData(Fts5Index *p){
if( p->pHash ){
sqlite3Fts5HashClear(p->pHash);
p->nPendingData = 0;
p->nPendingRow = 0;
}
p->nContentlessDelete = 0;
}
@ -4627,7 +4634,7 @@ static void fts5IndexMergeLevel(
/* Read input from all segments in the input level */
nInput = pLvl->nSeg;
/* Set the range of origins that will go into the output segment */
/* Set the range of origins that will go into the output segment. */
if( pStruct->nOriginCntr>0 ){
pSeg->iOrigin1 = pLvl->aSeg[0].iOrigin1;
pSeg->iOrigin2 = pLvl->aSeg[pLvl->nSeg-1].iOrigin2;
@ -4691,8 +4698,11 @@ static void fts5IndexMergeLevel(
int i;
/* Remove the redundant segments from the %_data table */
assert( pSeg->nEntry==0 );
for(i=0; i<nInput; i++){
fts5DataRemoveSegment(p, &pLvl->aSeg[i]);
Fts5StructureSegment *pOld = &pLvl->aSeg[i];
pSeg->nEntry += (pOld->nEntry - pOld->nEntryTombstone);
fts5DataRemoveSegment(p, pOld);
}
/* Remove the redundant segments from the input level */
@ -4718,6 +4728,43 @@ static void fts5IndexMergeLevel(
if( pnRem ) *pnRem -= writer.nLeafWritten;
}
/*
** If this is not a contentless_delete=1 table, or if the 'delete-automerge'
** configuration option is set to 0, then this function always returns -1.
** Otherwise, it searches the structure object passed as the second argument
** for a level suitable for merging due to having a large number of
** tombstones in the tombstone hash. If one is found, its index is returned.
** Otherwise, if there is no suitable level, -1.
*/
static int fts5IndexFindDeleteMerge(Fts5Index *p, Fts5Structure *pStruct){
Fts5Config *pConfig = p->pConfig;
int iRet = -1;
if( pConfig->bContentlessDelete && pConfig->nDeleteAutomerge>0 ){
int ii;
int nBest = 0;
for(ii=0; ii<pStruct->nLevel; ii++){
Fts5StructureLevel *pLvl = &pStruct->aLevel[ii];
i64 nEntry = 0;
i64 nTomb = 0;
int iSeg;
for(iSeg=0; iSeg<pLvl->nSeg; iSeg++){
nEntry += pLvl->aSeg[iSeg].nEntry;
nTomb += pLvl->aSeg[iSeg].nEntryTombstone;
}
assert( nEntry>0 || pLvl->nSeg==0 );
if( nEntry>0 ){
int nPercent = (nTomb * 100) / nEntry;
if( nPercent>=pConfig->nDeleteAutomerge && nPercent>nBest ){
iRet = ii;
nBest = nPercent;
}
}
}
}
return iRet;
}
/*
** Do up to nPg pages of automerge work on the index.
**
@ -4738,51 +4785,28 @@ static int fts5IndexMerge(
int iBestLvl = 0; /* Level offering the most input segments */
int nBest = 0; /* Number of input segments on best level */
/* Set iBestLvl to the level to read input segments from. */
/* Set iBestLvl to the level to read input segments from. Or to -1 if
** there is no level suitable to merge segments from. */
assert( pStruct->nLevel>0 );
for(iLvl=0; iLvl<pStruct->nLevel; iLvl++){
Fts5StructureLevel *pLvl = &pStruct->aLevel[iLvl];
int nThisSeg = 0;
if( pLvl->nMerge ){
if( pLvl->nMerge>nBest ){
iBestLvl = iLvl;
nBest = pLvl->nMerge;
nBest = nMin;
}
break;
}
nThisSeg = pLvl->nSeg;
if( bTombstone && nThisSeg ){
int iSeg;
int nPg = 0;
int nTomb = 0;
for(iSeg=0; iSeg<pLvl->nSeg; iSeg++){
Fts5StructureSegment *pSeg = &pLvl->aSeg[iSeg];
nPg += pSeg->pgnoLast;
nTomb += pSeg->nPgTombstone;
}
nThisSeg += ((nTomb*FTS5_MERGE_TOMBSTONE_WEIGHT) / nPg);
}
if( nThisSeg>nBest ){
nBest = nThisSeg;
if( pLvl->nSeg>nBest ){
nBest = pLvl->nSeg;
iBestLvl = iLvl;
}
}
/* If nBest is still 0, then the index must be empty. */
#ifdef SQLITE_DEBUG
for(iLvl=0; nBest==0 && iLvl<pStruct->nLevel; iLvl++){
assert( pStruct->aLevel[iLvl].nSeg==0 );
if( nBest<nMin ){
iBestLvl = fts5IndexFindDeleteMerge(p, pStruct);
}
#endif
if( nBest<nMin && pStruct->aLevel[iBestLvl].nMerge==0 ){
if( bTombstone || p->pConfig->bContentlessDelete==0 ){
break;
}else{
bTombstone = 1;
continue;
}
}
if( iBestLvl<0 ) break;
bRet = 1;
fts5IndexMergeLevel(p, &pStruct, iBestLvl, &nRem);
if( p->rc==SQLITE_OK && pStruct->aLevel[iBestLvl].nMerge==0 ){
@ -5477,6 +5501,7 @@ static void fts5FlushOneHash(Fts5Index *p){
if( pStruct->nOriginCntr>0 ){
pSeg->iOrigin1 = pStruct->nOriginCntr;
pSeg->iOrigin2 = pStruct->nOriginCntr;
pSeg->nEntry = p->nPendingRow;
pStruct->nOriginCntr++;
}
pStruct->nSegment++;
@ -5498,10 +5523,11 @@ static void fts5FlushOneHash(Fts5Index *p){
*/
static void fts5IndexFlush(Fts5Index *p){
/* Unless it is empty, flush the hash table to disk */
if( p->nPendingData || (p->nContentlessDelete && p->pConfig->nAutomerge>0) ){
if( p->nPendingData || p->nContentlessDelete ){
assert( p->pHash );
p->nPendingData = 0;
fts5FlushOneHash(p);
p->nPendingData = 0;
p->nPendingRow = 0;
}
}
@ -5579,6 +5605,7 @@ int sqlite3Fts5IndexOptimize(Fts5Index *p){
assert( p->rc==SQLITE_OK );
fts5IndexFlush(p);
assert( p->nContentlessDelete==0 );
pStruct = fts5StructureRead(p);
fts5StructureInvalidate(p);
@ -5608,7 +5635,10 @@ int sqlite3Fts5IndexOptimize(Fts5Index *p){
** INSERT command.
*/
int sqlite3Fts5IndexMerge(Fts5Index *p, int nMerge){
Fts5Structure *pStruct = fts5StructureRead(p);
Fts5Structure *pStruct = 0;
fts5IndexFlush(p);
pStruct = fts5StructureRead(p);
if( pStruct ){
int nMin = p->pConfig->nUsermerge;
fts5StructureInvalidate(p);
@ -6130,6 +6160,9 @@ int sqlite3Fts5IndexBeginWrite(Fts5Index *p, int bDelete, i64 iRowid){
p->iWriteRowid = iRowid;
p->bDelete = bDelete;
if( bDelete==0 ){
p->nPendingRow++;
}
return fts5IndexReturn(p);
}
@ -6883,12 +6916,17 @@ int sqlite3Fts5IndexContentlessDelete(Fts5Index *p, i64 iOrigin, i64 iRowid){
Fts5Structure *pStruct;
pStruct = fts5StructureRead(p);
if( pStruct ){
int bFound = 0; /* True after pSeg->nEntryTombstone incr. */
int iLvl;
for(iLvl=0; iLvl<pStruct->nLevel; iLvl++){
for(iLvl=pStruct->nLevel-1; iLvl>=0; iLvl--){
int iSeg;
for(iSeg=0; iSeg<pStruct->aLevel[iLvl].nSeg; iSeg++){
for(iSeg=pStruct->aLevel[iLvl].nSeg-1; iSeg>=0; iSeg--){
Fts5StructureSegment *pSeg = &pStruct->aLevel[iLvl].aSeg[iSeg];
if( pSeg->iOrigin1<=(u64)iOrigin && pSeg->iOrigin2>=(u64)iOrigin ){
if( bFound==0 ){
pSeg->nEntryTombstone++;
bFound = 1;
}
fts5IndexTombstoneAdd(p, pSeg, iRowid);
}
}
@ -7980,7 +8018,7 @@ static int fts5structConnectMethod(
rc = sqlite3_declare_vtab(db,
"CREATE TABLE xyz("
"level, segment, merge, segid, leaf1, leaf2, loc1, loc2, "
"npgtombstone, struct HIDDEN);"
"npgtombstone, nentrytombstone, nentry, struct HIDDEN);"
);
if( rc==SQLITE_OK ){
pNew = sqlite3Fts5MallocZero(&rc, sizeof(*pNew));
@ -8007,7 +8045,7 @@ static int fts5structBestIndexMethod(
pIdxInfo->idxNum = 0;
for(i=0, p=pIdxInfo->aConstraint; i<pIdxInfo->nConstraint; i++, p++){
if( p->usable==0 ) continue;
if( p->op==SQLITE_INDEX_CONSTRAINT_EQ && p->iColumn==9 ){
if( p->op==SQLITE_INDEX_CONSTRAINT_EQ && p->iColumn==11 ){
rc = SQLITE_OK;
pIdxInfo->aConstraintUsage[i].omit = 1;
pIdxInfo->aConstraintUsage[i].argvIndex = 1;
@ -8120,15 +8158,21 @@ static int fts5structColumnMethod(
case 5: /* leaf2 */
sqlite3_result_int(ctx, pSeg->pgnoLast);
break;
case 6: /* loc1 */
sqlite3_result_int(ctx, pSeg->iOrigin1);
case 6: /* origin1 */
sqlite3_result_int64(ctx, pSeg->iOrigin1);
break;
case 7: /* loc2 */
sqlite3_result_int(ctx, pSeg->iOrigin2);
case 7: /* origin2 */
sqlite3_result_int64(ctx, pSeg->iOrigin2);
break;
case 8: /* npgtombstone */
sqlite3_result_int(ctx, pSeg->nPgTombstone);
break;
case 9: /* nentrytombstone */
sqlite3_result_int64(ctx, pSeg->nEntryTombstone);
break;
case 10: /* nentry */
sqlite3_result_int64(ctx, pSeg->nEntry);
break;
}
return SQLITE_OK;
}

View File

@ -192,6 +192,5 @@ do_execsql_test 3.7 {
} {2 0 0}
finish_test

View File

@ -39,19 +39,36 @@ do_execsql_test 1.0 {
)
INSERT INTO ft SELECT document(12) FROM s;
}
do_execsql_test 1.1 {
INSERT INTO ft(ft) VALUES('optimize');
}
do_execsql_test 1.2 {
SELECT level, segment, nentry, nentrytombstone FROM fts5_structure((
SELECT block FROM ft_data WHERE id=10
))
} {0 0 1000 0}
do_execsql_test 1.3 {
DELETE FROM ft WHERE rowid < 50
}
do_execsql_test 1.4 {
SELECT level, segment, nentry, nentrytombstone FROM fts5_structure((
SELECT block FROM ft_data WHERE id=10
))
} {0 0 1000 49}
do_execsql_test 1.5 {
DELETE FROM ft WHERE rowid < 1000
}
execsql_pp {
SELECT * FROM fts5_structure((
do_execsql_test 1.6 {
SELECT level, segment, nentry, nentrytombstone FROM fts5_structure((
SELECT block FROM ft_data WHERE id=10
))
}
} {1 0 1 0}
finish_test

View File

@ -1,5 +1,5 @@
C Integrate\scontentless\sdelete\swith\sauto-merge.
D 2023-07-22T19:47:46.359
C Add\sthe\sfts5\s'delete-automerge'\sinteger\soption.\sA\slevel\sis\seligible\sfor\sauto-merging\sif\sit\shas\sa\sgreater\sthan\sor\sequal\spercentage\sof\sits\sentries\sdeleted\sby\stombstones\sthan\sthe\s'delete-automerge'\soption.\sDefault\svalue\sis\s10.
D 2023-07-24T19:13:06.235
F .fossil-settings/empty-dirs dbb81e8fc0401ac46a1491ab34a7f2c7c0452f2f06b54ebb845d024ca8283ef1
F .fossil-settings/ignore-glob 35175cdfcf539b2318cb04a9901442804be81cd677d8b889fcc9149c21f239ea
F LICENSE.md df5091916dbb40e6e9686186587125e1b2ff51f022cc334e886c19a0e9982724
@ -86,13 +86,13 @@ F ext/fts3/unicode/mkunicode.tcl d5aebf022fa4577ee8cdf27468f0d847879993959101f6d
F ext/fts3/unicode/parseunicode.tcl a981bd6466d12dd17967515801c3ff23f74a281be1a03cf1e6f52a6959fc77eb
F ext/fts5/extract_api_docs.tcl a36e54ec777172ddd3f9a88daf593b00848368e0
F ext/fts5/fts5.h c132a9323f22a972c4c93a8d5a3d901113a6e612faf30ca8e695788438c5ca2a
F ext/fts5/fts5Int.h f59c14f725ad0fcb8a81b9bf012e5021c6501bf43e73aa00b00d728e2ac7efaf
F ext/fts5/fts5Int.h 7decc306406187d1826c5eba9b8e8e6661b85580e5da1203760c0c2de9bc4a5e
F ext/fts5/fts5_aux.c 572d5ec92ba7301df2fea3258576332f2f4d2dfd66d8263afd157d9deceac480
F ext/fts5/fts5_buffer.c 3001fbabb585d6de52947b44b455235072b741038391f830d6b729225eeaf6a5
F ext/fts5/fts5_config.c 010fabcc0aaa0dfa76b19146e8bddf7de368933eeac01e294af6607447500caa
F ext/fts5/fts5_config.c c35f3433586f9152af2f444356020bf5c0f2e1d0fae29e67ab45de81277d07c9
F ext/fts5/fts5_expr.c 2473c13542f463cae4b938c498d6193c90d38ea1a2a4f9849c0479736e50d24d
F ext/fts5/fts5_hash.c 60224220ccfb2846b741b6dbb1b8872094ec6d87b3118c04244dafc83e6f9c40
F ext/fts5/fts5_index.c 31b8c8dd6913d76d6d7755342e36816495e5ad177d253f6bf39e0efdb9dc31e0
F ext/fts5/fts5_hash.c 65e7707bc8774706574346d18c20218facf87de3599b995963c3e6d6809f203d
F ext/fts5/fts5_index.c f5d50e218db3d32dd12c83b3f700bf79ed7f24e3f60f43f5b56e62146e2c31b5
F ext/fts5/fts5_main.c 2f87ee44fdb21539c264541149f07f70e065d58f37420063e5ddef80ba0f5ede
F ext/fts5/fts5_storage.c 3c9b41fce41b6410f2e8f82eb035c6a29b2560483f773e6dc98cf3cb2e4ddbb5
F ext/fts5/fts5_tcl.c b1445cbe69908c411df8084a10b2485500ac70a9c747cdc8cda175a3da59d8ae
@ -134,8 +134,8 @@ F ext/fts5/test/fts5connect.test 08030168fc96fc278fa81f28654fb7e90566f33aff269c0
F ext/fts5/test/fts5content.test 213506436fb2c87567b8e31f6d43ab30aab99354cec74ed679f22aad0cdbf283
F ext/fts5/test/fts5contentless.test 9a42a86822670792ba632f5c57459addeb774d93b29d5e6ddae08faa64c2b6d9
F ext/fts5/test/fts5contentless2.test 12c778d134a121b8bad000fbf3ae900d53226fee840ce36fe941b92737f1fda7
F ext/fts5/test/fts5contentless3.test cd3b8332c737d1d6f28e04d6338876c79c22815b8ecd34fb677409a013a45224
F ext/fts5/test/fts5contentless4.test 3b11ccbbe928d45eb8f985c0137a8fe2c69b70b940b10de31540040de5674311
F ext/fts5/test/fts5contentless3.test 487dce16b6677f68b44d7cbd158b9b7275d25e2c14d713f9188d9645bb699286
F ext/fts5/test/fts5contentless4.test 4403cbbbb5021b36b24d914addb22a782699d1a03a98fede705793f7184dbcd8
F ext/fts5/test/fts5corrupt.test 77ae6f41a7eba10620efb921cf7dbe218b0ef232b04519deb43581cb17a57ebe
F ext/fts5/test/fts5corrupt2.test 7453752ba12ce91690c469a6449d412561cc604b1dec994e16ab132952e7805f
F ext/fts5/test/fts5corrupt3.test 7da9895dafa404efd20728f66ff4b94399788bdc042c36fe2689801bba2ccd78
@ -2048,8 +2048,8 @@ F vsixtest/vsixtest.tcl 6a9a6ab600c25a91a7acc6293828957a386a8a93
F vsixtest/vsixtest.vcxproj.data 2ed517e100c66dc455b492e1a33350c1b20fbcdc
F vsixtest/vsixtest.vcxproj.filters 37e51ffedcdb064aad6ff33b6148725226cd608e
F vsixtest/vsixtest_TemporaryKey.pfx e5b1b036facdb453873e7084e1cae9102ccc67a0
P e61c9b083f5e0b6b6ee18f9394581ad816f445dbfb72ed1fe954f4182755a576
R 223fbfa40ff414b8abee5011e5d1533a
P 85c1589ab1fc69d1eef4bbc1bdefa2b10af5f6b9c08e813130b93829b592f416
R 067f23316c8bda4fd7b0c9766119547d
U dan
Z 8cb202caf797f67b895f2a9a207e2618
Z b4aabcdd1189dc9cd9c674268a107cec
# Remove this line to create a well-formed Fossil manifest.

View File

@ -1 +1 @@
85c1589ab1fc69d1eef4bbc1bdefa2b10af5f6b9c08e813130b93829b592f416
b314be66b9ac0190b5373b3b6baec012382bc588c2d86c2edab796669a4303c3