/**
* Copyright (C) 2009-2013 FoundationDB, LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.foundationdb.server.service.text;
import com.foundationdb.ais.model.AkibanInformationSchema;
import com.foundationdb.ais.model.FullTextIndex;
import com.foundationdb.ais.model.Index;
import com.foundationdb.ais.model.Index.IndexType;
import com.foundationdb.ais.model.IndexName;
import com.foundationdb.ais.model.Routine;
import com.foundationdb.ais.model.Sequence;
import com.foundationdb.ais.model.Table;
import com.foundationdb.ais.model.TableName;
import com.foundationdb.ais.model.aisb2.AISBBasedBuilder;
import com.foundationdb.ais.model.aisb2.NewAISBuilder;
import com.foundationdb.qp.operator.API;
import com.foundationdb.qp.operator.Cursor;
import com.foundationdb.qp.operator.Operator;
import com.foundationdb.qp.operator.QueryBindings;
import com.foundationdb.qp.operator.QueryContext;
import com.foundationdb.qp.operator.RowCursor;
import com.foundationdb.qp.operator.SimpleQueryContext;
import com.foundationdb.qp.operator.StoreAdapter;
import com.foundationdb.qp.row.HKey;
import com.foundationdb.qp.row.Row;
import com.foundationdb.qp.row.ValuesHKey;
import com.foundationdb.qp.row.ValuesHolderRow;
import com.foundationdb.qp.rowtype.HKeyRowType;
import com.foundationdb.qp.rowtype.RowType;
import com.foundationdb.qp.util.SchemaCache;
import com.foundationdb.server.error.AkibanInternalException;
import com.foundationdb.server.service.Service;
import com.foundationdb.server.service.config.ConfigurationService;
import com.foundationdb.server.service.listener.ListenerService;
import com.foundationdb.server.service.listener.RowListener;
import com.foundationdb.server.service.listener.TableListener;
import com.foundationdb.server.service.session.Session;
import com.foundationdb.server.service.session.SessionService;
import com.foundationdb.server.service.transaction.TransactionService;
import com.foundationdb.server.service.transaction.TransactionService.Callback;
import com.foundationdb.server.service.transaction.TransactionService.CallbackType;
import com.foundationdb.server.service.transaction.TransactionService.CloseableTransaction;
import com.foundationdb.server.store.SchemaManager;
import com.foundationdb.server.store.Store;
import com.foundationdb.sql.server.ServerCallContextStack;
import com.foundationdb.sql.server.ServerQueryContext;
import com.foundationdb.util.Exceptions;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.search.Query;
import com.google.inject.Inject;
import com.persistit.Key;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.util.Arrays;
import java.util.Collection;
import java.util.Iterator;
import java.util.NoSuchElementException;
public class FullTextIndexServiceImpl extends FullTextIndexInfosImpl
implements FullTextIndexService, Service, TableListener, RowListener
{
private static final Logger logger = LoggerFactory.getLogger(FullTextIndexServiceImpl.class);
public static final String INDEX_PATH_PROPERTY = "fdbsql.text.indexpath";
public static final String BACKGROUND_INTERVAL_PROPERTY = "fdbsql.text.backgroundInterval";
private static final TableName CHANGES_TABLE = new TableName(TableName.INFORMATION_SCHEMA, "full_text_changes");
private static final TableName BACKGROUND_WAIT_PROC_NAME = new TableName(TableName.SYS_SCHEMA, "full_text_background_wait");
private final ConfigurationService configService;
private final SessionService sessionService;
private final ListenerService listenerService;
private final SchemaManager schemaManager;
private final Store store;
private final TransactionService transactionService;
private final Object BACKGROUND_CHANGE_LOCK = new Object();
private final Object BACKGROUND_UPDATE_LOCK = new Object();
private BackgroundRunner backgroundUpdate;
private long backgroundInterval;
private File indexPath;
@Inject
public FullTextIndexServiceImpl(ConfigurationService configService,
SessionService sessionService,
ListenerService listenerService,
SchemaManager schemaManager,
Store store,
TransactionService transactionService) {
this.configService = configService;
this.sessionService = sessionService;
this.listenerService = listenerService;
this.schemaManager = schemaManager;
this.store = store;
this.transactionService = transactionService;
}
//
// FullTextIndexService
//
private void dropIndex(Session session, FullTextIndex index) {
logger.trace("Delete {}", index.getIndexName());
synchronized(BACKGROUND_UPDATE_LOCK) {
FullTextIndexInfo info = getIndex(session, index.getIndexName(), index.getIndexedTable().getAIS());
try {
info.close();
} catch(IOException e) {
logger.error("Error closing index {} on drop", index.getIndexName(), e);
}
// Delete documents
info.deletePath();
synchronized(indexes) {
indexes.remove(index.getIndexName());
}
}
}
@Override
public RowCursor searchIndex(QueryContext context, IndexName name, Query query, int limit) {
FullTextIndexInfo index = getIndex(context.getSession(), name, null);
try {
return index.getSearcher().search(context, index.getHKeyRowType(), query, limit);
}
catch (IOException ex) {
throw new AkibanInternalException("Error searching index", ex);
}
}
@Override
public void backgroundWait() {
waitUpdateCycle();
}
//
// Service
//
@Override
public void start() {
indexPath = new File(configService.getProperty(INDEX_PATH_PROPERTY));
boolean success = indexPath.mkdirs();
if(!success && !indexPath.exists()) {
throw new AkibanInternalException("Could not create indexPath directories: " + indexPath);
}
registerSystemTables();
listenerService.registerTableListener(this);
listenerService.registerRowListener(this);
backgroundInterval = Long.parseLong(configService.getProperty(BACKGROUND_INTERVAL_PROPERTY));
enableUpdateWorker();
}
@Override
public void stop() {
disableUpdateWorker();
listenerService.deregisterTableListener(this);
listenerService.deregisterRowListener(this);
synchronized(indexes) {
for(FullTextIndexShared index : indexes.values()) {
try {
index.close();
} catch(IOException e) {
logger.warn("Error closing index {}", index.getName(), e);
}
}
indexes.clear();
}
backgroundInterval = 0;
indexPath = null;
}
@Override
public void crash() {
stop();
}
/* FullTextIndexInfosImpl */
@Override
protected File getIndexPath() {
return indexPath;
}
@Override
protected AkibanInformationSchema getAIS(Session session) {
return schemaManager.getAis(session);
}
private void populateIndex(Session session, FullTextIndex index) {
final FullTextIndexInfo indexInfo = getIndex(session, index.getIndexName(), index.getIndexedTable().getAIS());
boolean success = false;
try {
StoreAdapter adapter = store.createAdapter(session);
QueryContext queryContext = new SimpleQueryContext(adapter);
Cursor cursor = null;
Indexer indexer = indexInfo.getIndexer();
try(RowIndexer rowIndexer = new RowIndexer(indexInfo, indexer.getWriter(), false)) {
cursor = API.cursor(indexInfo.fullScan(), queryContext, queryContext.createBindings());
long count = rowIndexer.indexRows(cursor);
logger.debug("Populated {} with {} rows", indexInfo.getIndex().getIndexName(), count);
} finally {
if(cursor != null && !cursor.isClosed()) {
cursor.close();
}
}
transactionService.addCallback(session, CallbackType.COMMIT, new Callback() {
@Override
public void run(Session session, long timestamp) {
try {
indexInfo.commitIndexer();
} catch(IOException e) {
logger.error("Error committing index {}", indexInfo.getIndex().getIndexName(), e);
}
}
});
success = true;
} catch(IOException e) {
throw new AkibanInternalException("Error populating index " + index, e);
} finally {
if(!success) {
try {
indexInfo.rollbackIndexer();
} catch(IOException e) {
logger.error("Error rolling back index population for {}", index, e);
}
synchronized(indexes) {
indexes.remove(index.getIndexName());
}
}
}
}
private void updateIndex(Session session, FullTextIndexInfo indexInfo, Iterable<byte[]> rows) throws IOException {
StoreAdapter adapter = store.createAdapter(session);
QueryContext queryContext = new SimpleQueryContext(adapter);
QueryBindings queryBindings = queryContext.createBindings();
Cursor cursor = null;
IndexWriter writer = indexInfo.getIndexer().getWriter();
try(RowIndexer rowIndexer = new RowIndexer(indexInfo, writer, true)) {
Operator operator = indexInfo.getOperator();
Iterator<byte[]> it = rows.iterator();
while(it.hasNext()) {
byte[] row = it.next();
Row hkeyRow = toHKeyRow(row, indexInfo.getHKeyRowType(), adapter);
queryBindings.setRow(0, hkeyRow);
cursor = API.cursor(operator, queryContext, queryBindings);
rowIndexer.updateDocument(cursor, row);
it.remove();
}
} finally {
if(cursor != null && !cursor.isClosed()) {
cursor.close();
}
}
}
//
// TableListener
//
@Override
public void onCreate(Session session, Table table) {
for(Index index : table.getFullTextIndexes()) {
populateIndex(session, (FullTextIndex)index);
}
}
@Override
public void onDrop(Session session, Table table) {
for(FullTextIndex index : table.getFullTextIndexes()) {
dropIndex(session, index);
}
}
@Override
public void onTruncate(Session session, Table table, boolean isFast) {
if(isFast) {
for(FullTextIndex index : table.getFullTextIndexes()) {
if(index.getIndexType() == IndexType.FULL_TEXT) {
throw new IllegalStateException("Cannot fast truncate: " + index);
}
}
}
}
@Override
public void onCreateIndex(Session session, Collection<? extends Index> indexes) {
for(Index index : indexes) {
if(index.getIndexType() == IndexType.FULL_TEXT) {
populateIndex(session, (FullTextIndex)index);
}
}
}
@Override
public void onDropIndex(Session session, Collection<? extends Index> indexes) {
for(Index index : indexes) {
if(index.getIndexType() == IndexType.FULL_TEXT) {
dropIndex(session, (FullTextIndex)index);
}
}
}
//
// RowListener
//
@Override
public void onInsertPost(Session session, Table table, Key hKey, Row row) {
trackChange(session, table, hKey);
}
@Override
public void onUpdatePre(Session session, Table table, Key hKey, Row oldRow, Row newRow) {
// None
}
@Override
public void onUpdatePost(Session session, Table table, Key hKey, Row oldRow, Row newRow) {
trackChange(session, table, hKey);
}
@Override
public void onDeletePre(Session session, Table table, Key hKey, Row row) {
trackChange(session, table, hKey);
}
// ---------- mostly for testing ---------------
public void disableUpdateWorker() {
synchronized(BACKGROUND_CHANGE_LOCK) {
assert backgroundUpdate != null;
backgroundUpdate.toFinished();
backgroundUpdate = null;
}
}
public void enableUpdateWorker() {
synchronized(BACKGROUND_CHANGE_LOCK) {
assert backgroundUpdate == null;
backgroundUpdate = new BackgroundRunner("FullText_Update", backgroundInterval, new Runnable() {
@Override
public void run() {
runUpdate();
}
});
backgroundUpdate.start();
}
}
public void waitUpdateCycle() {
synchronized(BACKGROUND_CHANGE_LOCK) {
if(backgroundUpdate != null) {
backgroundUpdate.waitForCycle();
}
}
}
private void runUpdate() {
// Consume and commit updates to each index in distinct blocks to keep r/w window small-ish
for(;;) {
try(Session session = sessionService.createSession();
CloseableTransaction txn = transactionService.beginCloseableTransaction(session)) {
// Quick exit if we won't see any
if(changesRowCount(session) == 0) {
break;
}
// Only interact with FullTextIndexInfo under lock as to not fight concurrent DROP
synchronized(BACKGROUND_UPDATE_LOCK) {
FullTextIndexInfo indexInfo = null;
try(HKeyBytesStream rows = new HKeyBytesStream(session)) {
if(rows.hasStream()) {
IndexName name = rows.getIndexName();
indexInfo = getIndexIfExists(session, name, null);
if(indexInfo == null) {
// Index has been deleted. Will conflict on so give up.
break;
} else {
updateIndex(session, indexInfo, rows);
}
}
txn.commit();
// Only commit changes to Lucene after successful iteration
// and removal of pending update rows
if(indexInfo != null) {
indexInfo.commitIndexer();
indexInfo = null;
}
} catch(IOException e) {
throw new AkibanInternalException("Error updating index", e);
} finally {
if(indexInfo != null) {
try {
indexInfo.rollbackIndexer();
} catch(IOException e) {
logger.warn( "Error rolling back update to {}", indexInfo.getIndex().getIndexName(), e);
}
}
}
}
}
}
}
private Row toHKeyRow(byte rowBytes[], HKeyRowType hKeyRowType, StoreAdapter store)
{
HKey hkey = store.getKeyCreator().newHKey(hKeyRowType.hKey());
hkey.copyFrom(rowBytes);
if (hkey instanceof ValuesHKey) {
return ((Row)(ValuesHKey)hkey);
} else {
throw new UnsupportedOperationException("HKey type is not ValuesHKey");
}
}
private class HKeyBytesStream implements Iterable<byte[]>, Closeable
{
private IndexName indexName;
private int indexID;
private final Session session;
private Cursor cursor;
private Row row;
private HKeyBytesStream(Session session) {
this.session = session;
AkibanInformationSchema ais = getAIS(session);
Table changesTable = ais.getTable(CHANGES_TABLE);
Operator plan = API.groupScan_Default(changesTable.getGroup());
StoreAdapter adapter = store.createAdapter(session);
QueryContext context = new SimpleQueryContext(adapter);
this.cursor = API.cursor(plan, context, context.createBindings());
cursor.open();
findNextIndex();
}
private void findNextIndex() {
indexName = null;
while((row = cursor.next()) != null) {
String schema = row.value(0).getString();
String tableName = row.value(1).getString();
String iName = row.value(2).getString();
indexName = new IndexName(new TableName(schema, tableName), iName);
indexID = row.value(3).getInt32();
Table table = getAIS(session).getTable(indexName.getFullTableName());
Index index = (table != null) ? table.getFullTextIndex(indexName.getName()) : null;
// May have been deleted or recreated
if(index != null && index.getIndexId() == indexID) {
break;
}
store.deleteRow(session, row, false);
indexName = null;
row = null;
}
}
public boolean hasStream() {
return indexName != null;
}
public IndexName getIndexName() {
return indexName;
}
@Override
public void close() {
if(cursor != null) {
cursor.close();
cursor = null;
row = null;
indexName = null;
}
}
@Override
public Iterator<byte[]> iterator() {
return new StreamIterator(hasStream(), row);
}
private class StreamIterator implements Iterator<byte[]> {
private Boolean hasNext;
private Row row;
private StreamIterator(boolean hasNext, Row row) {
this.hasNext = hasNext;
this.row = row;
}
private void advance() {
row = cursor.next();
if(row != null &&
indexName.getSchemaName().equals(row.value(0).getString()) &&
indexName.getTableName().equals(row.value(1).getString()) &&
indexName.getName().equals(row.value(2).getString()) &&
indexID == row.value(3).getInt32()) {
hasNext = true;
} else {
hasNext = false;
row = null;
}
}
@Override
public boolean hasNext() {
if(hasNext == null) {
advance();
}
return hasNext;
}
@Override
public byte[] next() {
if(hasNext == null) {
advance();
}
if(!hasNext) {
throw new NoSuchElementException();
}
hasNext = null;
return row.value(4).getBytes();
}
@Override
public void remove() {
if(row == null) {
throw new IllegalStateException();
}
store.deleteRow(session, row, false);
}
}
}
private void trackChange(Session session, Table table, Key hKey) {
RowType rowType = SchemaCache.globalSchema(getAIS(session)).tableRowType(getAIS(session).getTable(CHANGES_TABLE));
Sequence sequence = rowType.table().getIdentityColumn().getIdentityGenerator();
Long identity = store.nextSequenceValue(session, sequence);
for (Index index : table.getFullTextIndexes()) {
ValuesHolderRow row = new ValuesHolderRow(rowType,
index.getIndexName().getSchemaName(),
index.getIndexName().getTableName(),
index.getIndexName().getName(),
index.getIndexId(),
Arrays.copyOf(hKey.getEncodedBytes(), hKey.getEncodedSize()),
identity);
store.writeRow(session, row, null, null);
}
}
private long changesRowCount(Session session) {
return store.getAIS(session).getTable(CHANGES_TABLE).tableStatus().getRowCount(session);
}
private void registerSystemTables() {
final int identMax = 128;
final int tableVersion = 1;
final String schema = TableName.INFORMATION_SCHEMA;
NewAISBuilder builder = AISBBasedBuilder.create(schema, schemaManager.getTypesTranslator());
// TODO: Hidden PK too expensive?
builder.table(CHANGES_TABLE)
.colString("schema_name", identMax, false)
.colString("table_name", identMax, false)
.colString("index_name", identMax, false)
.colInt("index_id", false)
.colVarBinary("hkey", 4096, false);
builder.procedure(BACKGROUND_WAIT_PROC_NAME)
.language("java", Routine.CallingConvention.JAVA)
.externalName(Routines.class.getName(), "backgroundWait");
AkibanInformationSchema ais = builder.ais();
schemaManager.registerStoredInformationSchemaTable(ais.getTable(CHANGES_TABLE), tableVersion);
schemaManager.registerSystemRoutine(ais.getRoutine(BACKGROUND_WAIT_PROC_NAME));
}
@SuppressWarnings("unused") // Called reflectively
public static class Routines {
public static void backgroundWait() {
ServerQueryContext context = ServerCallContextStack.getCallingContext();
FullTextIndexService ft = context.getServer().getServiceManager().getServiceByClass(FullTextIndexService.class);
ft.backgroundWait();
}
}
public enum STATE {
NOT_STARTED,
RUNNING,
STOPPING,
FINISHED
}
public class BackgroundRunner extends Thread {
private final Object SLEEP_MONITOR = new Object();
private final Runnable runnable;
private final long sleepMillis;
private volatile STATE state;
private long runCount;
public BackgroundRunner(String name, long sleepMillis, Runnable runnable) {
super(name);
this.runnable = runnable;
this.sleepMillis = sleepMillis;
this.state = STATE.NOT_STARTED;
}
@Override
public void run() {
state = STATE.RUNNING;
while(state != STATE.STOPPING) {
try {
runInternal();
} catch(Exception e) {
if(!Exceptions.isRollbackException(e)) {
logger.error("Run failed with exception", getName(), e);
}
}
sleep();
}
}
public synchronized void toFinished() {
checkRunning();
state = STATE.STOPPING;
sleepNotify();
waitWhile(STATE.STOPPING, STATE.FINISHED);
notifyAll();
}
public synchronized void waitForCycle() {
checkRunning();
// +2 ensures that we've observed one full run no matter where we initially started
long target = runCount + 2;
while(runCount < target) {
sleepNotify();
waitThenSet(null);
}
}
public void sleepNotify() {
synchronized(SLEEP_MONITOR) {
SLEEP_MONITOR.notify();
}
}
//
// Helpers
//
private void runInternal() {
runnable.run();
synchronized(this) {
++runCount;
notifyAll();
}
}
private void checkRunning() {
if(state == STATE.STOPPING || state == STATE.FINISHED) {
throw new IllegalStateException("Not RUNNING: " + state);
}
}
private void waitWhile(STATE whileState, STATE newState) {
while(state == whileState) {
waitThenSet(newState);
}
}
private void waitThenSet(STATE newState) {
try {
wait();
if(newState != null) {
state = newState;
}
} catch(InterruptedException e) {
// None
}
}
private void sleep() {
try {
synchronized(SLEEP_MONITOR) {
SLEEP_MONITOR.wait(sleepMillis);
}
} catch(InterruptedException e) {
// None
}
}
}
}