From af42d5b67183568aeb519523a8f94a447d4f2365 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Wed, 21 Aug 2019 22:14:38 -0400 Subject: [PATCH] Create system for job migrations. --- .../securesms/ApplicationContext.java | 2 + .../securesms/database/JobDatabase.java | 33 ++++++ .../securesms/jobmanager/JobController.java | 1 - .../securesms/jobmanager/JobManager.java | 26 ++++- .../securesms/jobmanager/JobMigration.java | 62 ++++++++++ .../securesms/jobmanager/JobMigrator.java | 88 +++++++++++++++ .../jobmanager/persistence/JobStorage.java | 3 + .../securesms/jobs/FastJobStorage.java | 17 +++ .../securesms/jobs/JobManagerFactories.java | 6 + .../securesms/util/TextSecurePreferences.java | 10 ++ .../securesms/jobmanager/JobMigratorTest.java | 106 ++++++++++++++++++ .../securesms/jobs/FastJobStorageTest.java | 37 ++++++ 12 files changed, 388 insertions(+), 3 deletions(-) create mode 100644 src/org/thoughtcrime/securesms/jobmanager/JobMigration.java create mode 100644 src/org/thoughtcrime/securesms/jobmanager/JobMigrator.java create mode 100644 test/unitTest/java/org/thoughtcrime/securesms/jobmanager/JobMigratorTest.java diff --git a/src/org/thoughtcrime/securesms/ApplicationContext.java b/src/org/thoughtcrime/securesms/ApplicationContext.java index 295f3af70c..5b9122ada0 100644 --- a/src/org/thoughtcrime/securesms/ApplicationContext.java +++ b/src/org/thoughtcrime/securesms/ApplicationContext.java @@ -40,6 +40,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider; import org.thoughtcrime.securesms.gcm.FcmJobService; import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobmanager.JobMigrator; import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer; import org.thoughtcrime.securesms.jobs.CreateSignedPreKeyJob; import org.thoughtcrime.securesms.jobs.FastJobStorage; @@ -222,6 +223,7 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi .setConstraintFactories(JobManagerFactories.getConstraintFactories(this)) .setConstraintObservers(JobManagerFactories.getConstraintObservers(this)) .setJobStorage(new FastJobStorage(DatabaseFactory.getJobDatabase(this))) + .setJobMigrator(new JobMigrator(TextSecurePreferences.getJobManagerVersion(this), 1, JobManagerFactories.getJobMigrations())) .build()); } diff --git a/src/org/thoughtcrime/securesms/database/JobDatabase.java b/src/org/thoughtcrime/securesms/database/JobDatabase.java index 1a64eed210..39968bd0bd 100644 --- a/src/org/thoughtcrime/securesms/database/JobDatabase.java +++ b/src/org/thoughtcrime/securesms/database/JobDatabase.java @@ -141,6 +141,39 @@ public class JobDatabase extends Database { databaseHelper.getWritableDatabase().update(Jobs.TABLE_NAME, contentValues, null, null); } + public synchronized void updateJobs(@NonNull List jobs) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + + try { + for (JobSpec job : jobs) { + ContentValues values = new ContentValues(); + values.put(Jobs.JOB_SPEC_ID, job.getId()); + values.put(Jobs.FACTORY_KEY, job.getFactoryKey()); + values.put(Jobs.QUEUE_KEY, job.getQueueKey()); + values.put(Jobs.CREATE_TIME, job.getCreateTime()); + values.put(Jobs.NEXT_RUN_ATTEMPT_TIME, job.getNextRunAttemptTime()); + values.put(Jobs.RUN_ATTEMPT, job.getRunAttempt()); + values.put(Jobs.MAX_ATTEMPTS, job.getMaxAttempts()); + values.put(Jobs.MAX_BACKOFF, job.getMaxBackoff()); + values.put(Jobs.MAX_INSTANCES, job.getMaxInstances()); + values.put(Jobs.LIFESPAN, job.getLifespan()); + values.put(Jobs.SERIALIZED_DATA, job.getSerializedData()); + values.put(Jobs.IS_RUNNING, job.isRunning() ? 1 : 0); + + String query = Jobs.JOB_SPEC_ID + " = ?"; + String[] args = new String[]{ job.getId() }; + + db.update(Jobs.TABLE_NAME, values, query, args); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + public synchronized void deleteJobs(@NonNull List jobIds) { SQLiteDatabase db = databaseHelper.getWritableDatabase(); diff --git a/src/org/thoughtcrime/securesms/jobmanager/JobController.java b/src/org/thoughtcrime/securesms/jobmanager/JobController.java index 947800f194..7ae55f0639 100644 --- a/src/org/thoughtcrime/securesms/jobmanager/JobController.java +++ b/src/org/thoughtcrime/securesms/jobmanager/JobController.java @@ -63,7 +63,6 @@ class JobController { @WorkerThread synchronized void init() { - jobStorage.init(); jobStorage.updateAllJobsToBePending(); notifyAll(); } diff --git a/src/org/thoughtcrime/securesms/jobmanager/JobManager.java b/src/org/thoughtcrime/securesms/jobmanager/JobManager.java index 13d2368c53..6f57639bc5 100644 --- a/src/org/thoughtcrime/securesms/jobmanager/JobManager.java +++ b/src/org/thoughtcrime/securesms/jobmanager/JobManager.java @@ -4,6 +4,7 @@ import android.app.Application; import android.content.Intent; import android.os.Build; import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; import org.thoughtcrime.securesms.jobmanager.impl.DefaultExecutorFactory; import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer; @@ -11,6 +12,7 @@ import org.thoughtcrime.securesms.jobmanager.workmanager.WorkManagerMigrator; import org.thoughtcrime.securesms.jobmanager.persistence.JobStorage; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.util.Debouncer; +import org.thoughtcrime.securesms.util.TextSecurePreferences; import java.util.ArrayList; import java.util.Collections; @@ -58,6 +60,12 @@ public class JobManager implements ConstraintObserver.Notifier { WorkManagerMigrator.migrate(application, configuration.getJobStorage(), configuration.getDataSerializer()); } + JobStorage jobStorage = configuration.getJobStorage(); + jobStorage.init(); + + int latestVersion = configuration.getJobMigrator().migrate(jobStorage, configuration.getDataSerializer()); + TextSecurePreferences.setJobManagerVersion(application, latestVersion); + jobController.init(); for (ConstraintObserver constraintObserver : configuration.getConstraintObservers()) { @@ -214,6 +222,7 @@ public class JobManager implements ConstraintObserver.Notifier { private final List constraintObservers; private final Data.Serializer dataSerializer; private final JobStorage jobStorage; + private final JobMigrator jobMigrator; private Configuration(int jobThreadCount, @NonNull ExecutorFactory executorFactory, @@ -221,7 +230,8 @@ public class JobManager implements ConstraintObserver.Notifier { @NonNull ConstraintInstantiator constraintInstantiator, @NonNull List constraintObservers, @NonNull Data.Serializer dataSerializer, - @NonNull JobStorage jobStorage) + @NonNull JobStorage jobStorage, + @NonNull JobMigrator jobMigrator) { this.executorFactory = executorFactory; this.jobThreadCount = jobThreadCount; @@ -230,6 +240,7 @@ public class JobManager implements ConstraintObserver.Notifier { this.constraintObservers = constraintObservers; this.dataSerializer = dataSerializer; this.jobStorage = jobStorage; + this.jobMigrator = jobMigrator; } int getJobThreadCount() { @@ -261,6 +272,10 @@ public class JobManager implements ConstraintObserver.Notifier { return jobStorage; } + @NonNull JobMigrator getJobMigrator() { + return jobMigrator; + } + public static class Builder { private ExecutorFactory executorFactory = new DefaultExecutorFactory(); @@ -270,6 +285,7 @@ public class JobManager implements ConstraintObserver.Notifier { private List constraintObservers = new ArrayList<>(); private Data.Serializer dataSerializer = new JsonDataSerializer(); private JobStorage jobStorage = null; + private JobMigrator jobMigrator = null; public @NonNull Builder setJobThreadCount(int jobThreadCount) { this.jobThreadCount = jobThreadCount; @@ -306,6 +322,11 @@ public class JobManager implements ConstraintObserver.Notifier { return this; } + public @NonNull Builder setJobMigrator(@NonNull JobMigrator jobMigrator) { + this.jobMigrator = jobMigrator; + return this; + } + public @NonNull Configuration build() { return new Configuration(jobThreadCount, executorFactory, @@ -313,7 +334,8 @@ public class JobManager implements ConstraintObserver.Notifier { new ConstraintInstantiator(constraintFactories), new ArrayList<>(constraintObservers), dataSerializer, - jobStorage); + jobStorage, + jobMigrator); } } } diff --git a/src/org/thoughtcrime/securesms/jobmanager/JobMigration.java b/src/org/thoughtcrime/securesms/jobmanager/JobMigration.java new file mode 100644 index 0000000000..5c86926f0c --- /dev/null +++ b/src/org/thoughtcrime/securesms/jobmanager/JobMigration.java @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.jobmanager; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Create a subclass of this to perform a migration on persisted {@link Job}s. A migration targets + * a specific end version, and the assumption is that it can migrate jobs to that end version from + * the previous version. The class will be provided a bundle of job data for each persisted job and + * give back an updated version (if applicable). + */ +public abstract class JobMigration { + + private final int endVersion; + + protected JobMigration(int endVersion) { + this.endVersion = endVersion; + } + + /** + * Given a bundle of job data, return a bundle of job data that should be used in place of it. + * You may obviously return the same object if you don't wish to change it. + */ + protected abstract @NonNull JobData migrate(@NonNull JobData jobData); + + int getEndVersion() { + return endVersion; + } + + protected static class JobData { + + private final String factoryKey; + private final String queueKey; + private final Data data; + + JobData(@NonNull String factoryKey, @Nullable String queueKey, @NonNull Data data) { + this.factoryKey = factoryKey; + this.queueKey = queueKey; + this.data = data; + } + + protected @NonNull JobData withQueueKey(@Nullable String newQueueKey) { + return new JobData(factoryKey, newQueueKey, data); + } + + protected @NonNull JobData withData(@NonNull Data newData) { + return new JobData(factoryKey, queueKey, newData); + } + + public @NonNull String getFactoryKey() { + return factoryKey; + } + + public @Nullable String getQueueKey() { + return queueKey; + } + + public @NonNull Data getData() { + return data; + } + } +} diff --git a/src/org/thoughtcrime/securesms/jobmanager/JobMigrator.java b/src/org/thoughtcrime/securesms/jobmanager/JobMigrator.java new file mode 100644 index 0000000000..58646bf662 --- /dev/null +++ b/src/org/thoughtcrime/securesms/jobmanager/JobMigrator.java @@ -0,0 +1,88 @@ +package org.thoughtcrime.securesms.jobmanager; + +import android.annotation.SuppressLint; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.jobmanager.JobMigration.JobData; +import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec; +import org.thoughtcrime.securesms.jobmanager.persistence.JobStorage; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.ListIterator; +import java.util.Map; + +@SuppressLint("UseSparseArrays") +public class JobMigrator { + + private static final String TAG = Log.tag(JobMigrator.class); + + private final int lastSeenVersion; + private final int currentVersion; + private final Map migrations; + + public JobMigrator(int lastSeenVersion, int currentVersion, @NonNull List migrations) { + this.lastSeenVersion = lastSeenVersion; + this.currentVersion = currentVersion; + this.migrations = new HashMap<>(); + + if (migrations.size() != currentVersion - 1) { + throw new AssertionError("You must have a migration for every version!"); + } + + for (int i = 0; i < migrations.size(); i++) { + JobMigration migration = migrations.get(i); + + if (migration.getEndVersion() != i + 2) { + throw new AssertionError("Missing migration for version " + (i + 2) + "!"); + } + + this.migrations.put(migration.getEndVersion(), migrations.get(i)); + } + } + + /** + * @return The version that has been migrated to. + */ + int migrate(@NonNull JobStorage jobStorage, @NonNull Data.Serializer dataSerializer) { + List jobSpecs = jobStorage.getAllJobSpecs(); + + for (int i = lastSeenVersion; i < currentVersion; i++) { + Log.i(TAG, "Migrating from " + i + " to " + (i + 1)); + + ListIterator iter = jobSpecs.listIterator(); + JobMigration migration = migrations.get(i + 1); + + assert migration != null; + + while (iter.hasNext()) { + JobSpec jobSpec = iter.next(); + Data data = dataSerializer.deserialize(jobSpec.getSerializedData()); + JobData originalJobData = new JobData(jobSpec.getFactoryKey(), jobSpec.getQueueKey(), data); + JobData updatedJobData = migration.migrate(originalJobData); + JobSpec updatedJobSpec = new JobSpec(jobSpec.getId(), + jobSpec.getFactoryKey(), + updatedJobData.getQueueKey(), + jobSpec.getCreateTime(), + jobSpec.getNextRunAttemptTime(), + jobSpec.getRunAttempt(), + jobSpec.getMaxAttempts(), + jobSpec.getMaxBackoff(), + jobSpec.getLifespan(), + jobSpec.getMaxInstances(), + dataSerializer.serialize(updatedJobData.getData()), + jobSpec.isRunning()); + + iter.set(updatedJobSpec); + } + } + + jobStorage.updateJobs(jobSpecs); + + return currentVersion; + } +} diff --git a/src/org/thoughtcrime/securesms/jobmanager/persistence/JobStorage.java b/src/org/thoughtcrime/securesms/jobmanager/persistence/JobStorage.java index b7c035ac60..3f05abff92 100644 --- a/src/org/thoughtcrime/securesms/jobmanager/persistence/JobStorage.java +++ b/src/org/thoughtcrime/securesms/jobmanager/persistence/JobStorage.java @@ -35,6 +35,9 @@ public interface JobStorage { @WorkerThread void updateAllJobsToBePending(); + @WorkerThread + void updateJobs(@NonNull List jobSpecs); + @WorkerThread void deleteJob(@NonNull String id); diff --git a/src/org/thoughtcrime/securesms/jobs/FastJobStorage.java b/src/org/thoughtcrime/securesms/jobs/FastJobStorage.java index 3c5da4cc2c..4423ce7502 100644 --- a/src/org/thoughtcrime/securesms/jobs/FastJobStorage.java +++ b/src/org/thoughtcrime/securesms/jobs/FastJobStorage.java @@ -212,6 +212,23 @@ public class FastJobStorage implements JobStorage { } } + @Override + public void updateJobs(@NonNull List jobSpecs) { + jobDatabase.updateJobs(jobSpecs); + + Map updates = Stream.of(jobSpecs).collect(Collectors.toMap(JobSpec::getId)); + ListIterator iter = jobs.listIterator(); + + while (iter.hasNext()) { + JobSpec existing = iter.next(); + JobSpec update = updates.get(existing.getId()); + + if (update != null) { + iter.set(update); + } + } + } + @Override public synchronized void deleteJob(@NonNull String jobId) { deleteJobs(Collections.singletonList(jobId)); diff --git a/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 080da95cc2..4ff7274e10 100644 --- a/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -6,6 +6,7 @@ import androidx.annotation.NonNull; import org.thoughtcrime.securesms.jobmanager.Constraint; import org.thoughtcrime.securesms.jobmanager.ConstraintObserver; import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobMigration; import org.thoughtcrime.securesms.jobmanager.impl.CellServiceConstraint; import org.thoughtcrime.securesms.jobmanager.impl.CellServiceConstraintObserver; import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; @@ -18,6 +19,7 @@ import org.thoughtcrime.securesms.migrations.LegacyMigrationJob; import org.thoughtcrime.securesms.migrations.MigrationCompleteJob; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -102,4 +104,8 @@ public final class JobManagerFactories { new NetworkConstraintObserver(application), new SqlCipherMigrationConstraintObserver()); } + + public static List getJobMigrations() { + return Collections.emptyList(); + } } diff --git a/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java b/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java index 5b7fc0a42e..ffb6758650 100644 --- a/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java +++ b/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java @@ -187,6 +187,8 @@ public class TextSecurePreferences { private static final String SEEN_CAMERA_FIRST_TOOLTIP = "pref_seen_camera_first_tooltip"; + private static final String JOB_MANAGER_VERSION = "pref_job_manager_version"; + public static boolean isScreenLockEnabled(@NonNull Context context) { return getBooleanPreference(context, SCREEN_LOCK, false); } @@ -1118,6 +1120,14 @@ public class TextSecurePreferences { return getBooleanPreference(context, SEEN_CAMERA_FIRST_TOOLTIP, false); } + public static void setJobManagerVersion(Context context, int version) { + setIntegerPrefrence(context, JOB_MANAGER_VERSION, version); + } + + public static int getJobManagerVersion(Context contex) { + return getIntegerPreference(contex, JOB_MANAGER_VERSION, 1); + } + public static void setBooleanPreference(Context context, String key, boolean value) { PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean(key, value).apply(); } diff --git a/test/unitTest/java/org/thoughtcrime/securesms/jobmanager/JobMigratorTest.java b/test/unitTest/java/org/thoughtcrime/securesms/jobmanager/JobMigratorTest.java new file mode 100644 index 0000000000..c588ad31fe --- /dev/null +++ b/test/unitTest/java/org/thoughtcrime/securesms/jobmanager/JobMigratorTest.java @@ -0,0 +1,106 @@ +package org.thoughtcrime.securesms.jobmanager; + +import androidx.annotation.NonNull; + +import org.junit.BeforeClass; +import org.junit.Test; +import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec; +import org.thoughtcrime.securesms.jobmanager.persistence.JobStorage; +import org.thoughtcrime.securesms.logging.Log; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class JobMigratorTest { + + @BeforeClass + public static void init() { + Log.initialize(mock(Log.Logger.class)); + } + + @Test(expected = AssertionError.class) + public void JobMigrator_crashWhenTooFewMigrations() { + new JobMigrator(1, 2, Collections.emptyList()); + } + + @Test(expected = AssertionError.class) + public void JobMigrator_crashWhenTooManyMigrations() { + new JobMigrator(1, 2, Arrays.asList(new EmptyMigration(2), new EmptyMigration(3))); + } + + @Test(expected = AssertionError.class) + public void JobMigrator_crashWhenSkippingMigrations() { + new JobMigrator(1, 3, Arrays.asList(new EmptyMigration(2), new EmptyMigration(4))); + } + + @Test + public void JobMigrator_properInitialization() { + new JobMigrator(1, 3, Arrays.asList(new EmptyMigration(2), new EmptyMigration(3))); + } + + @Test + public void migrate_callsAppropriateMigrations_fullSet() { + JobMigration migration1 = spy(new EmptyMigration(2)); + JobMigration migration2 = spy(new EmptyMigration(3)); + + JobMigrator subject = new JobMigrator(1, 3, Arrays.asList(migration1, migration2)); + int version = subject.migrate(simpleJobStorage(), mock(Data.Serializer.class)); + + assertEquals(3, version); + verify(migration1).migrate(any()); + verify(migration2).migrate(any()); + } + + @Test + public void migrate_callsAppropriateMigrations_subset() { + JobMigration migration1 = spy(new EmptyMigration(2)); + JobMigration migration2 = spy(new EmptyMigration(3)); + + JobMigrator subject = new JobMigrator(2, 3, Arrays.asList(migration1, migration2)); + int version = subject.migrate(simpleJobStorage(), mock(Data.Serializer.class)); + + assertEquals(3, version); + verify(migration1, never()).migrate(any()); + verify(migration2).migrate(any()); + } + + @Test + public void migrate_callsAppropriateMigrations_none() { + JobMigration migration1 = spy(new EmptyMigration(2)); + JobMigration migration2 = spy(new EmptyMigration(3)); + + JobMigrator subject = new JobMigrator(3, 3, Arrays.asList(migration1, migration2)); + int version = subject.migrate(simpleJobStorage(), mock(Data.Serializer.class)); + + assertEquals(3, version); + verify(migration1, never()).migrate(any()); + verify(migration2, never()).migrate(any()); + } + + private static JobStorage simpleJobStorage() { + JobStorage jobStorage = mock(JobStorage.class); + when(jobStorage.getAllJobSpecs()).thenReturn(new ArrayList<>(Collections.singletonList(new JobSpec("1", "f1", null, 1, 1, 1, 1, 1, 1, 1, "", false)))); + return jobStorage; + } + + private static class EmptyMigration extends JobMigration { + + protected EmptyMigration(int endVersion) { + super(endVersion); + } + + @Override + protected @NonNull JobData migrate(@NonNull JobData jobData) { + return jobData; + } + } +} diff --git a/test/unitTest/java/org/thoughtcrime/securesms/jobs/FastJobStorageTest.java b/test/unitTest/java/org/thoughtcrime/securesms/jobs/FastJobStorageTest.java index 280d5a89b6..8b9717eef9 100644 --- a/test/unitTest/java/org/thoughtcrime/securesms/jobs/FastJobStorageTest.java +++ b/test/unitTest/java/org/thoughtcrime/securesms/jobs/FastJobStorageTest.java @@ -101,6 +101,43 @@ public class FastJobStorageTest { assertFalse(subject.getJobSpec("2").isRunning()); } + @Test + public void updateJobs_writesToDatabase() { + JobDatabase database = noopDatabase(); + FastJobStorage subject = new FastJobStorage(database); + List jobs = Collections.emptyList(); + + subject.updateJobs(jobs); + + verify(database).updateJobs(jobs); + } + + @Test + public void updateJobs_updatesAllFields() { + + FullSpec fullSpec1 = new FullSpec(new JobSpec("1", "f1", null, 1, 1, 1, 1, 1, 1, 1, EMPTY_DATA, false), + Collections.emptyList(), + Collections.emptyList()); + FullSpec fullSpec2 = new FullSpec(new JobSpec("2", "f2", null, 1, 1, 1, 1, 1, 1, 1, EMPTY_DATA, false), + Collections.emptyList(), + Collections.emptyList()); + FullSpec fullSpec3 = new FullSpec(new JobSpec("3", "f3", null, 1, 1, 1, 1, 1, 1, 1, EMPTY_DATA, false), + Collections.emptyList(), + Collections.emptyList()); + + FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Arrays.asList(fullSpec1, fullSpec2, fullSpec3))); + + JobSpec update1 = new JobSpec("1", "g1", "q1", 2, 2, 2, 2, 2, 2, 2, "abc", true); + JobSpec update2 = new JobSpec("2", "g2", "q2", 3, 3, 3, 3, 3, 3, 3, "def", true); + + subject.init(); + subject.updateJobs(Arrays.asList(update1, update2)); + + assertEquals(update1, subject.getJobSpec("1")); + assertEquals(update2, subject.getJobSpec("2")); + assertEquals(fullSpec3.getJobSpec(), subject.getJobSpec("3")); + } + @Test public void updateJobRunningState_writesToDatabase() { JobDatabase database = noopDatabase();