/** 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.util.ReadWriteThreadManager; import android.util.SparseBooleanArray; /** * Like a normal timmy table, but uses a rollback journal instead of a roll forward journal * and has soft and hard commits. This helps when dealing with large database files and applying * lots of spread out changes during commit. What happens is that a soft commit is done many times and * a hard commit a few times. When a soft commit is done, all changes have been written, but not synced * to the file system, so that the rows can be removed from the cache (reading them will always return * the new row). When a hard commit is finally done, then everything is synced, but this can be done * rarely (a rollback will only go to the hard commit, though. */ public class RollBackTimmyTable 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[] ROLLBACK_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); private static final int SYNC_ID = -1; /** * The number of rows that we saved in the soft commit rollback file */ private static final long SOFT_COMMIT_NUM_ROWS_INDEX = ROLLBACK_MAGIC.length + (Integer.SIZE >> 3); // this first int is for the number of rows commited to the db before we started the public static final String ROLLBACK_EXTENSION = ".rb"; //transaction private int recordSize; /** * The size of the database before the transaction is started */ private int committedNextRowId; String filename; private int lastTransactionInsertId = Integer.MAX_VALUE; private OutputStream rollBackOut; private boolean inTransaction; private RandomAccessFile rwRaf; private FileOutputStream rollBackFileOut; private TimmyDatabase database; private boolean isTableCorrupt; private boolean isNew; private SparseBooleanArray softModeSavedRows = new SparseBooleanArray(); byte [] tempRecordData; /** * Whether we should be accepting updateRecordHard() or updateRecordSoft calls. * Starts out as true, since otherwise when we rollforward after a crash, we'd try to close * the soft write journal */ boolean hardWriteMode = true; //threadsafe (obviously) /** * Created by TimmmyDatabase */ protected RollBackTimmyTable(String filename, int recordSize, TimmyDatabase d) throws SyncFailedException, IOException { this.filename = filename; this.database = d; reopenRaf(recordSize); tempRecordData = new byte[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; } /** * Both a soft write and a hard write must be done in order to update a record. * All soft writes for a transaction must be done, then a soft commit and finally * hard writes are performed. Hard writes must write to the same rows that the soft writes did. * @param id * @param record * @throws IOException */ //thread safe because only one thread is allowed to update public void updateRecordSoft(int id) throws IOException { if(!database.isOpen()) throw new IllegalStateException("don't access the table before opening the database"); if(hardWriteMode) throw new IllegalStateException("soft write attempted during hard write mode"); if(id > lastTransactionInsertId) throw new IllegalStateException("attempt to update a row past the last inserted row"); //ignore soft updates past the soft record limit (updates to rows that have been inserted) //note, this is useful for multiple soft commits in a single transaction if(id >= committedNextRowId) return; //if we already saved the old value (we are updating the same row twice) if(softModeSavedRows.get(id, false)) return; softModeSavedRows.put(id, true); synchronized(this) { getRecord(tempRecordData, id); } Util.writeInt(rollBackOut, id); rollBackOut.write(tempRecordData); } /** * Goes back to writing soft updates after hard commit mode has already been * entered. This will append to the rollback log * @throws IOException */ public void revertToSoftCommitMode() throws IOException { if(!hardWriteMode) throw new IllegalStateException("Error, already in soft update"); hardWriteMode = false; File f = getRollBackFile(); rollBackOut = new BufferedOutputStream(rollBackFileOut = new FileOutputStream(f, true)); } /** * A hard update can only occur during hard mode and must only be the ids from the soft updates, * or an inserted record. * @param id * @param record * @throws IOException */ public void updateRecordHard(int id, byte[] record) throws IOException { if(!database.isOpen()) throw new IllegalStateException("don't access the table before opening the database"); if(!hardWriteMode) throw new IllegalStateException("hard write attempted during soft write mode"); if(id < committedNextRowId && !softModeSavedRows.get(id, false)) throw new IllegalStateException("hard write attempted for row not soft written: "+id); synchronized(this) { rwRaf.seek(HEADER_SIZE + id * recordSize); rwRaf.write(record); } } //threadsafe since it doesn't touch main db file private void writeRollBackHeader(int numRows) throws IOException { rollBackOut.write(ROLLBACK_MAGIC); Util.writeInt(rollBackOut, numRows); //this is the number of soft committed rows Util.writeInt(rollBackOut, 0); } /** * Within a transaction, all inserts must be done in sequential order. * They may be done during any commit stage, hard or soft (note, * the option to write during hard mode is needed by PropertyTimmyTable.writeProperties()) * * @param id * @param encryptRowWithEncodedUserDataKey * @throws IOException */ 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); } synchronized (this) { rwRaf.seek(HEADER_SIZE + recordSize * id); rwRaf.write(record); } lastTransactionInsertId = id; } /** * Only one thread is allowed to begin a transaction. */ //thread safety: only one thread allowed to do a transaction @Override 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; hardWriteMode = false; File f = getRollBackFile(); rollBackOut = new BufferedOutputStream(rollBackFileOut = new FileOutputStream(f, true)); writeRollBackHeader(committedNextRowId); //System.out.println("beginTransction end: "+this); } //threadsafe - doesnt touch map private File getRollBackFile() { return getRollBackFile(filename); } private static File getRollBackFile(String filename) { return new File(filename+ROLLBACK_EXTENSION); } /** * After this is called, all soft updates must be rerun as hard updates. * At that point the memory associated with update may be removed. * There may be no more inserts after this is called * @throws SyncFailedException * @throws IOException */ protected void softCommitTransaction() throws SyncFailedException, IOException { //System.out.println("commitTransactionStage1: "+this); //stage 1 //flush and sync rollback file rollBackOut.flush(); rollBackFileOut.getFD().sync(); if(softModeSavedRows.size() != 0) { //record the actualy number of rows in this commit block (at the beginning of it) RandomAccessFile rbRaf = new RandomAccessFile(getRollBackFile(), "rws"); rbRaf.seek(SOFT_COMMIT_NUM_ROWS_INDEX); rbRaf.writeInt(softModeSavedRows.size()); rbRaf.getFD().sync(); rbRaf.close(); } rollBackOut.close(); rollBackOut = null; rollBackFileOut = null; hardWriteMode = true; } //threadsafe - only one thread allowed to touch a transaction /** * Rolls back the last transaction. Also used when the db * is reopened * (the database tells us this) * @throws IOException */ @Override public boolean rollbackTransaction() throws IOException { //System.out.println("rollbackTransaction: "+this); if(inTransaction) { inTransaction = false; if(!hardWriteMode) { rollBackOut.close(); finishTransaction(); return true; } } //now we need to run the rollback file, if necessary File rollBackFile = getRollBackFile(); //if there is no rollback file then no permenant change was done to the file if(!rollBackFile.exists() || rollBackFile.length() == 0) return true; //System.out.println("commitTransactionStage2: "+this); InputStream rfIn = new BufferedInputStream(new FileInputStream(rollBackFile)); committedNextRowId = readRollBackHeader(rfIn); synchronized (this) { rwRaf.seek(NEXT_ROW_ID_POS); rwRaf.writeInt(committedNextRowId); rwRaf.setLength(HEADER_SIZE + recordSize * committedNextRowId); } int numSoftCommitRecords = Util.readInt(rfIn); byte [] recordData = new byte[recordSize]; byte [] idBytes = new byte[4]; for(int softCommitRecordsCount = 0; softCommitRecordsCount < numSoftCommitRecords; softCommitRecordsCount++) { if(database.isCancelOpen) return false; //read next roll back entry id. if there are no more records then quit if(!readFullyForRollBack(rfIn, idBytes, true)) break; int id = Util.byteArrayToInt(idBytes, 0); if(id > committedNextRowId) { setTableCorrupt(); throw new IllegalStateException("rollback log attempted to write to a row not in committed set," +" row id requested: "+id+", this: "+this); } //read row data readFullyForRollBack(rfIn, recordData, false); synchronized (this) { //write the record to the database rwRaf.seek(recordSize * id + HEADER_SIZE); rwRaf.write(recordData); } } //sync all our writes before we delete the rollback file rwRaf.getFD().sync(); rfIn.close(); getRollBackFile().delete(); //System.out.println("rollbackTransaction end: "+this); return true; } private void finishTransaction() { rollBackFileOut = null; rollBackOut = null; inTransaction = false; hardWriteMode = false; softModeSavedRows.clear(); } //thread safe - we synchronize all access to map /** * This commits after hard updates are done. It will do a sync against the table data, * but (TODO I think?) * that we can sync while we are reading which will allow us to access the table regardless * of whether we are commiting or no * @param rwtm */ @Override public boolean commitTransactionStage2(ReadWriteThreadManager rwtm) throws IOException { if(!hardWriteMode) { rollBackOut.close(); finishTransaction(); } //here we finish updating the database, and sync the file 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); //note that it's ok to write the number of rows before we // sync the data to the database only because we store the original value // in the rollback file rwRaf.seek(NEXT_ROW_ID_POS); rwRaf.writeInt(committedNextRowId); } } //this is supposed to sync to the file system //I hope we can do this while we read rwRaf.getFD().sync(); return true; } @Override public boolean needsProcessingTime(boolean needsRollforward) { if (!needsRollforward) { File rollBackFile = getRollBackFile(); if(rollBackFile.exists() && rollBackFile.length() >0) return true; } return false; } /** * Delete the rollback file to finish the commit * @throws IOException */ @Override public void commitTransactionStage3() throws IOException { //System.out.println("commitTransactionStage3: "+this); //delete the log file indicating that we're all done getRollBackFile().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 @Override 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 readFullyForRollBack(InputStream rfIn, byte[] buf, boolean recordBoundary) throws IOException { int c = Util.readFully(rfIn, buf); if(c != buf.length) { if(c == 0 && recordBoundary) return false; setTableCorrupt(); throw new IllegalStateException("database corrupted on rollback, got "+c+" bytes, expected "+recordSize+ ", bytes: "+Arrays.toString(buf)); } return true; } //all callers are thread safe /** * @return number of rows in timmy table at last commit */ private int readRollBackHeader(InputStream is) throws IOException { byte[] magicBuffer = new byte[ROLLBACK_MAGIC.length]; Util.readFully(is, magicBuffer); if (!Arrays.equals(ROLLBACK_MAGIC, magicBuffer)) { setTableCorrupt(); throw new IOException("Bad magic for rollback file of timmy table " + this + ", got " + Arrays.toString(magicBuffer)); } return Util.readInt(is); } //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 { setTableCorrupt(); throw new IllegalStateException("id is "+id+" recordSize is "+recordSize+" pos is "+ (id * recordSize)+", rwRaf.limit is "+rwRaf.length(), e); } catch (IOException e1) { setTableCorrupt(); 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() { return isTableCorrupt; } //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 */ @Override public void deleteTableFiles() { new File(filename).delete(); getRollBackFile(filename).delete(); } @Override public String toString() { return "TimmyTable [filename="+filename + ", recordSize=" + recordSize + ", committedNextRowId=" + committedNextRowId + ", filename=" + filename + ", lastTransactionInsertId=" + lastTransactionInsertId + ", rollBackOut=" + rollBackFileOut + ", inTransaction=" + inTransaction + ", raf=" + rwRaf + ", rollBackFileOut=" + rollBackFileOut + "]"; } @Override public boolean inStage2() { File f = getRollBackFile(); return !f.exists() || f.length() == 0; } @Override public void commitTransactionStage1() throws SyncFailedException, IOException { //noop, softCommit should already be run at this point } @Override public String getFilename() { return filename; } }