/**
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.IOException;
import java.io.SyncFailedException;
import java.util.ArrayList;
import com.rareventure.android.Util;
import com.rareventure.util.MultiValueHashMap;
import com.rareventure.util.ReadWriteThreadManager;
/**
* A timmy table is a special database-ish flat file. It's data must follow the
* following rules
* <ul>
* <li>Records must be a fixed size
* <li>Access to updates is not guaranteed to be before or after the commit
* when accessing at the same time as a commit (in a different thread)
* <li>No deletes.
* <li>Transactions must be done with inserts in sequential order.
* <li>Only one thread is allowed to manage a transaction, including performing
* inserts and updates and commiting or rolling it back
* <li>Have fun!
* <ul>
* Because of the above its not meant for inter process usage if writes are being
* made.
* <p>
* Each table has a table file with a header containing the max row id.
* When a transaction is done, inserts are placed after the max row id, and updates
* are made initially to a rollforward journal file, which also includes the new
* max row id
* <p>
* Then, the rollforward journal is run, the database is updated to the max id, and
* the rollfoward is then deleted.
* <p>
* In the result of a crash while the inserts are being made, they'll just be ignored.
* If a crash occurs when creating the update log, it will be ignored (we move it
* from "rollfoward.tmp" to "rollfoward.live" or something when done)
* If a crash occurs when rolling the live rollforward log, we just reroll it the
* next time we start.
* <p>
* A database links up all timmy tables together, so that a rollforward will only
* occur if they all have live rollforward journals. It's unbreakable!
*/
public class TimmyDatabase {
private static final int MASTER_TABLE_NAME_SIZE = 256;
private static final int MASTER_TABLE_VALUE_SIZE = 1024 - MASTER_TABLE_NAME_SIZE;
private static final String VERSION_KEYWORD = /* ttt_installer:obfuscate_str */"_tt_version";
private PropertyTimmyTable masterTable;
private MultiValueHashMap<String, String> propertyMap;
private ArrayList<ITimmyTable> tables;
private boolean isOpen;
private boolean inTransaction;
private boolean transactionSuccessful;
public boolean isCancelOpen;
public TimmyDatabase(String dbFilename) throws SyncFailedException, IOException
{
masterTable = new PropertyTimmyTable(dbFilename, MASTER_TABLE_NAME_SIZE, MASTER_TABLE_VALUE_SIZE, this);
tables = new ArrayList<ITimmyTable>();
tables.add(masterTable);
}
private void initProperties() throws IOException {
//if a fresh database
if(propertyMap.get(VERSION_KEYWORD) == null)
{
//setup initial properties
propertyMap.put(VERSION_KEYWORD, "0");
//save them
masterTable.beginTransaction();
masterTable.writeProperties(propertyMap);
masterTable.commitTransactionStage1();
masterTable.commitTransactionStage2(null);
masterTable.commitTransactionStage3();
}
}
public int getVersion()
{
return Integer.parseInt(propertyMap.getFirst(VERSION_KEYWORD));
}
/**
* Marks the database as corrupt. Useful if something has gone horribly wrong and
* the application can recover somehow.
*/
public void setCorrupt()
{
for(ITimmyTable tt: this.tables) {
tt.setTableCorrupt();
}
}
public boolean isCorrupt()
{
// if(2==2) return false; //xODO 2 hack!!
for(ITimmyTable tt: this.tables)
{
if(tt.isTableCorrupt())
return true;
}
return false;
}
/**
* This or addRollBackTimmyTable must be called for all tables in database before open()
* @param filename
* @param recordSize
* @return
* @throws SyncFailedException
* @throws IOException
*/
public TimmyTable addTimmyTable(String filename, int recordSize)
throws SyncFailedException, IOException {
if(isOpen)
throw new IllegalStateException("table can't be added after database is open");
TimmyTable tt = new TimmyTable(filename, recordSize, this);
tables.add(tt);
return tt;
}
public RollBackTimmyTable addRollBackTimmyTable(String filename, int recordSize)
throws SyncFailedException, IOException {
if(isOpen)
throw new IllegalStateException("table can't be added after database is open");
RollBackTimmyTable tt = new RollBackTimmyTable(filename, recordSize, this);
tables.add(tt);
return tt;
}
/**
* Must be called before interacting with the database and
* after all addTables() are done
*
* May be canceled by calling cancelOpen() (in another thread)
*
* @return true if opened successfully or false if canceled
* @throws IOException
*/
public boolean open() throws IOException
{
if(isCancelOpen)
return false;
//we don't know if we crashed or not so we have to cleanup
boolean inStage2 = needsRollforward();
if(inStage2)
{
for(ITimmyTable tt: tables)
{
if(!tt.commitTransactionStage2(null))
return false;
}
for(ITimmyTable tt: tables)
tt.commitTransactionStage3();
}
else
for(ITimmyTable tt: tables)
{
if(!tt.rollbackTransaction())
return false;
}
isOpen = true;
this.propertyMap = masterTable.readProperties();
initProperties();
return true;
}
/**
* Will cause the current open call (if any) running in another thread to cancel within a few
* seconds (hopefully). Any future
* open() calls will cancel immediately. Finishes immediately. Call resetCancelOpen() to
* allow open() calls to work again .
*/
public void cancelOpen()
{
isCancelOpen = true;
}
/**
* Once this method is called, all opens will work again.
*/
public void resetCancelOpen()
{
isCancelOpen = false;
}
/**
*
* @return true if the database may take awhile to open. Should be called before open
*/
public boolean needsProcessingTime()
{
boolean needsRollforward = needsRollforward();
for(ITimmyTable tt : tables)
{
if(tt.needsProcessingTime(needsRollforward))
{
return true;
}
}
return false;
}
private boolean needsRollforward() {
boolean inStage2 = true;
for(ITimmyTable tt : tables)
{
if(!tt.inStage2())
{
inStage2 = false;
break;
}
}
return inStage2;
}
//threadsafe - only one thread allowed to touch a transaction
/**
* Begins a transaction. Only one thread is allowed to begin a
* transaction or do any update or insert on a timmy table
* at a time.
*/
public void beginTransaction() throws IOException
{
if(!isOpen)
throw new IllegalStateException("database must be open before beginning a new transaction");
if(inTransaction)
{
throw new IllegalStateException("only one transaction can exist at a time");
}
inTransaction = true;
transactionSuccessful = false;
for(ITimmyTable tt : tables)
{
tt.beginTransaction();
}
}
//threadsafe - only one thread allowed to touch a transaction
public void setTransactionSuccessful() throws IOException {
transactionSuccessful = true;
}
public void endTransaction() throws IOException {
endTransaction(null);
}
//threadsafe - only one thread allowed to touch a transaction
public void endTransaction(ReadWriteThreadManager rwtm) throws IOException {
if(transactionSuccessful)
{
for(ITimmyTable tt : tables)
{
tt.commitTransactionStage1();
}
for(ITimmyTable tt : tables)
{
tt.commitTransactionStage2(rwtm);
}
for(ITimmyTable tt: tables)
tt.commitTransactionStage3();
}
else
for(ITimmyTable tt : tables)
{
tt.rollbackTransaction();
}
inTransaction = false;
//this is done because the memory maps have no way to release the
//mapping, except by letting the garbage collector get them
System.gc();
}
/**
* Deletes all database files. Database must be already closed
*/
public void deleteDatabase() throws IOException {
if(isOpen)
throw new IllegalStateException("db must be already closed");
for(ITimmyTable tt: tables)
tt.deleteTableFiles();
}
public void close() throws IOException {
for(ITimmyTable tt: tables)
tt.close();
isOpen = false;
}
public void setProperty(String name, Object value) {
propertyMap.remove(name);
propertyMap.put(name, String.valueOf(value));
}
public void saveProperties() throws IOException {
masterTable.writeProperties(propertyMap);
}
public boolean inTransaction() {
return inTransaction;
}
/**
* Returns the first value of a name or null
* if it doesn't exist
*/
public String getProperty(String name) {
return propertyMap.getFirst(name);
}
public boolean isOpen() {
return isOpen;
}
public TimmyTable getTable(String filename) {
for(ITimmyTable t : tables)
{
if(t.getFilename().equals(filename))
return (TimmyTable)t;
}
return null;
}
public RollBackTimmyTable getRollBackTable(String filename) {
for(ITimmyTable t : tables)
{
if(t.getFilename().equals(filename))
return (RollBackTimmyTable)t;
}
return null;
}
public int getIntProperty(String name, int defaultValue) {
return Util.parseIntIfPresent(getProperty(name), defaultValue);
}
public boolean isNew() {
return masterTable.isNew();
}
}