/**
Copyright 2015 Tim Engler, Rareventure LLC
This file is part of Tiny Travel Tracker.
Tiny Travel Tracker is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Tiny Travel Tracker 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 General Public License for more details.
You should have received a copy of the GNU General Public License
along with Tiny Travel Tracker. If not, see <http://www.gnu.org/licenses/>.
*/
package com.rareventure.android.database.timmy;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import java.io.SyncFailedException;
import java.util.Arrays;
import com.rareventure.android.Util;
import com.rareventure.gps2.GTG;
import com.rareventure.util.ReadWriteThreadManager;
public class TimmyTable implements ITimmyTable {
private static final byte VERSION = 1;
private static final long HEADER_SIZE = 64;
private static final byte[] MAGIC = "timmy!".getBytes();
private static final byte[] ROLLFORWARD_MAGIC = "TIMMY!".getBytes();
private static final int NEXT_ROW_ID_POS = MAGIC.length + 1; // +1 for version
private static final int RECORD_SIZE_POS = NEXT_ROW_ID_POS+(Integer.SIZE >> 3);
private static final int IS_CORRUPTED_FIELD_POS = RECORD_SIZE_POS+(Integer.SIZE >> 3);
public static final String ROLLFORWARD_EXTENSION = ".rf";
private int recordSize;
private int committedNextRowId;
String filename;
private int lastTransactionInsertId = Integer.MAX_VALUE;
private OutputStream rollForwardOut;
private boolean inTransaction;
private RandomAccessFile rwRaf;
//TODO 3 we don't need insertRecordOut anymore (we can just use rwRaf)
private OutputStream insertRecordOut;
private FileOutputStream insertRecordFileOut;
private FileOutputStream rollForwardFileOut;
private TimmyDatabase database;
private boolean isTableCorrupt;
private boolean isNew;
//threadsafe (obviously)
/**
* Created by TimmmyDatabase
*/
protected TimmyTable(String filename, int recordSize, TimmyDatabase d)
throws SyncFailedException, IOException {
////System.out.println("new: "+this);
this.filename = filename;
this.database = d;
reopenRaf(recordSize);
//System.out.println("new end: "+this);
}
protected boolean isNew()
{
return isNew;
}
//all callers are thread safe
private void readHeader(int expectedRecordSize) throws IOException {
rwRaf.seek(0);
byte[] magicBuffer = new byte[MAGIC.length];
readFully(rwRaf, magicBuffer);
if (!Arrays.equals(MAGIC, magicBuffer))
throw new IOException("Bad magic for timmy table " + this
+ ", got " + Arrays.toString(magicBuffer));
byte version = rwRaf.readByte();
if (version != VERSION)
throw new IOException("Wrong version for timmy table " + this
+ ", got " + version);
committedNextRowId = rwRaf.readInt();
this.recordSize = rwRaf.readInt();
this.isTableCorrupt = rwRaf.readByte() != 0;
if (expectedRecordSize != this.recordSize)
throw new IOException("Wrong recordsize for timmy table " + this
+ ", got " + this.recordSize + ", expected " + expectedRecordSize);
}
//all callers are thread safe
private void createHeader(int recordSize) throws SyncFailedException,
IOException {
rwRaf.setLength(HEADER_SIZE);
rwRaf.seek(0);
rwRaf.write(MAGIC);
rwRaf.write(VERSION);
rwRaf.writeInt(0); // nextRowId;
rwRaf.writeInt(recordSize);
rwRaf.getFD().sync();
this.recordSize = recordSize;
}
//all callers are thread safe
public static int readFully(RandomAccessFile raf, byte [] buffer) throws IOException
{
return readFully(raf, buffer, 0, buffer.length);
}
//all callers are thread safe
public static int readFully(RandomAccessFile raf, byte[] buffer, int offset, int length) throws IOException {
int totalRead = 0;
while (totalRead < length) {
int numRead = raf.read(buffer, offset + totalRead, length - totalRead);
if (numRead < 0) {
break;
}
totalRead += numRead;
}
return totalRead;
}
//threadsafe
public synchronized int getNextRowId()
{
if(!database.isOpen())
throw new IllegalStateException("don't access the table before opening the database");
if(inTransaction)
return lastTransactionInsertId+1;
//this changes whenever a commit is complete (with inserts)
return committedNextRowId;
}
//threadsafe (never changes)
public int getRecordSize() {
return recordSize;
}
//threadsafe since it doesn't touch main db file
public void updateRecord(int id, byte[] record) throws IOException {
if(!database.isOpen())
throw new IllegalStateException("don't access the table before opening the database");
Util.writeInt(rollForwardOut, id);
rollForwardOut.write(record);
}
//threadsafe since it doesn't touch main db file
private void writeRollForwardHeader() throws IOException {
rollForwardOut.write(ROLLFORWARD_MAGIC);
}
/**
* Within a transaction, all inserts must be done in sequential order
* @param id
* @throws IOException
*/
//threadsafe since it doesn't touch map
public void insertRecord(int id, byte[] record) throws IOException {
if(!database.isOpen())
throw new IllegalStateException("don't access the table before opening the database");
if(id < committedNextRowId)
throw new IllegalStateException("Trying to insert a row below nextRowId: "+id+", this: "+this);
if(id != lastTransactionInsertId+1)
throw new IllegalStateException("Trying to insert a row that is not one above the last transaction id: "
+lastTransactionInsertId +", id: "+id);
if(record.length != recordSize)
throw new IllegalStateException("Record is wrong size: "+record.length+", this: "+this);
insertRecordOut.write(record);
lastTransactionInsertId = id;
}
/**
* Only one thread is allowed to begin a transaction.
*/
//thread safety: only one thread allowed to do a transaction
public void beginTransaction() throws IOException {
//System.out.println("beginTransction: "+this);
if(inTransaction)
throw new IllegalStateException("You can't start a transaction twice. this: "+this);
inTransaction = true;
lastTransactionInsertId = committedNextRowId-1;
//truncate the database to the last committed row so inserts
// will be placed correctly
synchronized(this) {
rwRaf.setLength(HEADER_SIZE + recordSize * committedNextRowId);
}
//inserts are appended at the end of the file
//note, we could just add them to the roll forward log, but then we're copying them
//twice to disk, and so it's a little faster this way. Also it means there is only
//one type of transaction in the roll forward log (update) so it simplifies that
//quite a lot
insertRecordOut = new BufferedOutputStream(insertRecordFileOut = new FileOutputStream(filename, true));
rollForwardOut = new BufferedOutputStream(rollForwardFileOut =
new FileOutputStream(getRollForwardFile(true)));
writeRollForwardHeader();
//System.out.println("beginTransction end: "+this);
}
//threadsafe - doesnt touch map
private File getRollForwardFile(boolean isTemp) {
return getRollForwardFile(filename, isTemp);
}
private static File getRollForwardFile(String filename, boolean isTemp) {
return new File(filename+(isTemp ? ROLLFORWARD_EXTENSION+".tmp" : ROLLFORWARD_EXTENSION));
}
public void commitTransactionStage1() throws SyncFailedException, IOException
{
//System.out.println("commitTransactionStage1: "+this);
//stage 1
//flush and sync everything
insertRecordOut.flush();
insertRecordFileOut.getFD().sync();
insertRecordOut.close();
rollForwardOut.flush();
rollForwardFileOut.getFD().sync();
rollForwardOut.close();
//mark the roll back file as a real roll back file
getRollForwardFile(true).renameTo(getRollForwardFile(false));
//System.out.println("commitTransactionStage1 end: "+this);
}
//threadsafe - only one thread allowed to touch a transaction
/**
* Rolls back as long as we haven't gone beyond the point of no return
* (the database tells us this)
* @throws IOException
*/
public boolean rollbackTransaction() throws IOException {
//System.out.println("rollbackTransaction: "+this);
if(inTransaction)
{
rollForwardOut.close();
insertRecordOut.close();
finishTransaction();
}
getRollForwardFile(true).delete();
//we delete the real roll forward file because the database might be requesting
//the rollback, which can occur even if the rollforward log is ready to go
getRollForwardFile(false).delete();
synchronized(this)
{
rwRaf.setLength(HEADER_SIZE + recordSize * committedNextRowId);
}
//System.out.println("rollbackTransaction end: "+this);
return true;
}
private void finishTransaction()
{
insertRecordFileOut = null;
insertRecordOut = null;
rollForwardFileOut = null;
rollForwardOut = null;
inTransaction = false;
}
//thread safe - we synchronize all access to map
/**
* Splitting commits in multiple stages allows the database to coordinate transactions
* across multiple tables.
* @param rwtm
*/
public boolean commitTransactionStage2(ReadWriteThreadManager rwtm) throws IOException {
//System.out.println("commitTransactionStage2: "+this);
InputStream rfIn = new BufferedInputStream(new FileInputStream(getRollForwardFile(false)));
readRollForwardHeader(rfIn);
byte [] recordData = new byte[recordSize];
byte [] idBytes = new byte[4];
for(;;)
{
if(database.isCancelOpen)
return false;
//read roll forward. if there are no more records then quit
if(!readFullyForRollForward(rfIn, idBytes, true))
break;
int id = Util.byteArrayToInt(idBytes, 0);
if(id > committedNextRowId)
throw new IllegalStateException("rollforward log attempted to write to a row not in committed set,"
+" row id requested: "+id+", this: "+this);
readFullyForRollForward(rfIn, recordData, false);
synchronized (this)
{
//write the record to the database
rwRaf.seek(recordSize * id + HEADER_SIZE);
rwRaf.write(recordData);
}
if(rwtm != null && rwtm.isReadingThreadsActive())
rwtm.pauseForReadingThreads();
}
rfIn.close();
//here we finish updating the database
//and finally closing and reopening the map with
//the newly inserted rows
synchronized (this)
{
//if any rows were inserted we have to reset the maps
if(committedNextRowId != (int) ((rwRaf.length() - HEADER_SIZE) / recordSize))
{
committedNextRowId = (int) ((rwRaf.length() - HEADER_SIZE) / recordSize);
rwRaf.seek(NEXT_ROW_ID_POS);
rwRaf.writeInt(committedNextRowId);
}
}
//this is supposed to sync to the file system
//We let this sync happen outside of the synchronization loop, so that reads can still occur
//while we sync. It appears to work
rwRaf.getFD().sync();
// reopenRaf(recordSize);
//System.out.println("commitTransactionStage2 end: "+this);
return true;
}
/**
* Finally clean up stage2
* @throws IOException
*/
public void commitTransactionStage3() throws IOException
{
//System.out.println("commitTransactionStage3: "+this);
//delete the log file indicating that we're all done
getRollForwardFile(false).delete();
//we may be just opening the table and in that case there is no
//transaction
if(inTransaction)
finishTransaction();
//System.out.println("commitTransactionStage3 end: "+this);
}
//threadsafe - should not be called while reading
public void close() throws IOException {
//System.out.println("close: "+this);
if(inTransaction)
{
rollbackTransaction();
finishTransaction();
}
if(rwRaf != null)
{
rwRaf.close();
rwRaf = null;
}
//System.out.println("close end: "+this);
}
//all callers are thread safe
private void reopenRaf(int expectedRecordSize) throws IOException {
synchronized (this)
{
if(rwRaf != null)
{
rwRaf.close();
rwRaf = null;
}
rwRaf = new RandomAccessFile(filename, "rw");
if (rwRaf.length() == 0) {
createHeader(expectedRecordSize);
//we do this so we can open the read only map
rwRaf.getFD().sync();
isNew = true;
}
readHeader(expectedRecordSize);
}
}
//all callers are thread safe
private boolean readFullyForRollForward(InputStream rfIn, byte[] buf,
boolean recordBoundary) throws IOException {
int c = Util.readFully(rfIn, buf);
if(c != buf.length)
{
if(c == 0 && recordBoundary)
return false;
throw new IllegalStateException("database corrupted on rollforward, got "+c+" bytes, expected "+recordSize+
", bytes: "+Arrays.toString(buf));
}
return true;
}
//all callers are thread safe
private void readRollForwardHeader(InputStream is) throws IOException {
byte[] magicBuffer = new byte[ROLLFORWARD_MAGIC.length];
Util.readFully(is, magicBuffer);
if (!Arrays.equals(ROLLFORWARD_MAGIC, magicBuffer))
throw new IOException("Bad magic for rollforward file of timmy table " + this
+ ", got " + Arrays.toString(magicBuffer));
}
//threadsafe - map can be altered when writing a transaction out
public void getRecord(byte[] buf, int id) {
//System.out.println("getRecord: "+this.filename);
if(!database.isOpen())
throw new IllegalStateException("don't access the table before opening the database");
synchronized (this)
{
try {
if(((long)id) * recordSize + HEADER_SIZE >= rwRaf.length() || id < 0)
{
setTableCorrupt();
throw new IllegalStateException("record out of bounds for "+this+". table marked corrupt, got "+id);
}
rwRaf.seek(HEADER_SIZE + id * recordSize);
Util.readFully(rwRaf, buf);
}
catch(IOException e)
{
try {
throw new IllegalStateException("id is "+id+" recordSize is "+recordSize+" pos is "+
(id * recordSize)+", rwRaf.limit is "+rwRaf.length(), e);
} catch (IOException e1) {
throw new IllegalStateException("id is "+id+" recordSize is "+recordSize+" pos is "+
(id * recordSize)+", can't read rwRaf.limit", e);
}
}
}
//System.out.println("end getRecord: "+this.filename);
}
public boolean isTableCorrupt()
{
boolean result = GTG.HACK_MAKE_TT_CORRUPT || isTableCorrupt;
GTG.HACK_MAKE_TT_CORRUPT = false;
return result;
}
//threadsafe, called from synchronized block
public void setTableCorrupt() {
this.isTableCorrupt = true;
try {
rwRaf.seek(IS_CORRUPTED_FIELD_POS);
rwRaf.write(1);
rwRaf.getFD().sync();
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
/**
* Deletes database and all temporary files
*/
public void deleteTableFiles() {
new File(filename).delete();
getRollForwardFile(filename, true).delete();
getRollForwardFile(filename, false).delete();
}
@Override
public String toString() {
return "TimmyTable [filename="+filename + ", recordSize=" + recordSize
+ ", committedNextRowId=" + committedNextRowId + ", filename="
+ filename + ", lastTransactionInsertId="
+ lastTransactionInsertId + ", rollForwardOut="
+ rollForwardOut + ", inTransaction=" + inTransaction
+ ", raf=" + rwRaf + ", insertRecordOut=" + insertRecordOut
+ ", insertRecordFileOut=" + insertRecordFileOut
+ ", rollForwardFileOut=" + rollForwardFileOut + "]";
}
/**
*
* @return true if the table is past stage1 but stage 2 hasn't yet complete.
*/
public boolean inStage2() {
return getRollForwardFile(filename, false).exists();
}
@Override
public String getFilename() {
return filename;
}
@Override
public boolean needsProcessingTime(boolean needsRollforward) {
return needsRollforward;
}
}