diff --git a/src/Plugin/search_api/tracker/Basic.php b/src/Plugin/search_api/tracker/Basic.php
index 79ce7a13..dcf5feb5 100644
--- a/src/Plugin/search_api/tracker/Basic.php
+++ b/src/Plugin/search_api/tracker/Basic.php
@@ -266,6 +266,12 @@ public function trackItemsUpdated(array $ids = NULL) {
         if ($ids_chunk) {
           $update->condition('item_id', $ids_chunk, 'IN');
         }
+        // Update the status of unindexed items only if the item order is LIFO.
+        // (Otherwise, an item that's regularly being updated might never get
+        // indexed.)
+        if ($this->configuration['indexing_order'] === 'fifo') {
+          $update->condition('status', static::STATUS_INDEXED);
+        }
         $update->execute();
       }
     }
@@ -364,45 +370,69 @@ public function trackAllItemsDeleted($datasource_id = NULL) {
    * {@inheritdoc}
    */
   public function getRemainingItems($limit = -1, $datasource_id = NULL) {
-    $select = $this->createRemainingItemsStatement($datasource_id);
-    if ($limit >= 0) {
-      $select->range(0, $limit);
+    try {
+      $select = $this->createRemainingItemsStatement($datasource_id);
+      if ($limit >= 0) {
+        $select->range(0, $limit);
+      }
+      return $select->execute()->fetchCol();
+    }
+    catch (\Exception $e) {
+      $this->logException($e);
+      return [];
     }
-    return $select->execute()->fetchCol();
   }
 
   /**
    * {@inheritdoc}
    */
   public function getTotalItemsCount($datasource_id = NULL) {
-    $select = $this->createSelectStatement();
-    if ($datasource_id) {
-      $select->condition('datasource', $datasource_id);
+    try {
+      $select = $this->createSelectStatement();
+      if ($datasource_id) {
+        $select->condition('datasource', $datasource_id);
+      }
+      return (int) $select->countQuery()->execute()->fetchField();
+    }
+    catch (\Exception $e) {
+      $this->logException($e);
+      return 0;
     }
-    return (int) $select->countQuery()->execute()->fetchField();
   }
 
   /**
    * {@inheritdoc}
    */
   public function getIndexedItemsCount($datasource_id = NULL) {
-    $select = $this->createSelectStatement();
-    $select->condition('sai.status', $this::STATUS_INDEXED);
-    if ($datasource_id) {
-      $select->condition('datasource', $datasource_id);
+    try {
+      $select = $this->createSelectStatement();
+      $select->condition('sai.status', $this::STATUS_INDEXED);
+      if ($datasource_id) {
+        $select->condition('datasource', $datasource_id);
+      }
+      return (int) $select->countQuery()->execute()->fetchField();
+    }
+    catch (\Exception $e) {
+      $this->logException($e);
+      return 0;
     }
-    return (int) $select->countQuery()->execute()->fetchField();
   }
 
   /**
    * {@inheritdoc}
    */
   public function getRemainingItemsCount($datasource_id = NULL) {
-    $select = $this->createRemainingItemsStatement();
-    if ($datasource_id) {
-      $select->condition('datasource', $datasource_id);
+    try {
+      $select = $this->createRemainingItemsStatement();
+      if ($datasource_id) {
+        $select->condition('datasource', $datasource_id);
+      }
+      return (int) $select->countQuery()->execute()->fetchField();
+    }
+    catch (\Exception $e) {
+      $this->logException($e);
+      return 0;
     }
-    return (int) $select->countQuery()->execute()->fetchField();
   }
 
 }
diff --git a/tests/src/Kernel/BasicTrackerTest.php b/tests/src/Kernel/BasicTrackerTest.php
new file mode 100644
index 00000000..36a1b6e7
--- /dev/null
+++ b/tests/src/Kernel/BasicTrackerTest.php
@@ -0,0 +1,384 @@
+<?php
+
+namespace Drupal\Tests\search_api\Kernel;
+
+use Drupal\Core\Database\Connection;
+use Drupal\Core\Database\Transaction;
+use Drupal\Core\Logger\RfcLogLevel;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\search_api\Entity\Index;
+use Drupal\search_api\Utility\Utility;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Tests the "default" tracker plugin.
+ *
+ * @group search_api
+ *
+ * @coversDefaultClass \Drupal\search_api\Plugin\search_api\tracker\Basic
+ */
+class BasicTrackerTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'user',
+    'search_api',
+    'system',
+  ];
+
+  /**
+   * The tracker plugin used for this test.
+   *
+   * @var \Drupal\search_api\Plugin\search_api\tracker\Basic
+   */
+  protected $tracker;
+
+  /**
+   * The search index used for this test.
+   *
+   * @var \Drupal\search_api\IndexInterface
+   */
+  protected $index;
+
+  /**
+   * The test time service used for this test.
+   *
+   * @var \Drupal\Tests\search_api\Kernel\TestTimeService
+   */
+  protected $timeService;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    parent::setUp();
+
+    $this->installSchema('search_api', ['search_api_item']);
+    $this->installConfig('search_api');
+
+    // Do not use a batch for tracking the initial items after creating an
+    // index when running the tests via the GUI. Otherwise, it seems Drupal's
+    // Batch API gets confused and the test fails.
+    if (!Utility::isRunningInCli()) {
+      \Drupal::state()->set('search_api_use_tracking_batch', FALSE);
+    }
+
+    $this->index = Index::create([
+      'id' => 'index',
+      'tracker_settings' => [
+        'default' => [],
+      ],
+    ]);
+    $this->tracker = $this->index->getTrackerInstance();
+    $this->timeService = new TestTimeService();
+    $this->tracker->setTimeService($this->timeService);
+  }
+
+  /**
+   * Tests tracking.
+   *
+   * @param string $indexing_order
+   *   The indexing order setting to use – "fifo" or "lifo".
+   *
+   * @dataProvider trackingDataProvider
+   */
+  public function testTracking($indexing_order) {
+    // Add a logger that throws an exception when used, so a caught exception
+    // within any of the tracker methods will still cause a test fail.
+    // @todo Change to createMock() once we depend on Drupal 8.5+. See #2930839.
+    /** @var \PHPUnit_Framework_MockObject_MockObject|\Psr\Log\LoggerInterface $logger */
+    $logger = $this->getMock(LoggerInterface::class);
+    $logger->method('log')
+      ->willReturnCallback(function ($level, $message, array $variables) {
+        $error = 'Tracking operation threw ';
+        $error .= strtr($message, $variables);
+        throw new \Exception($error);
+      });
+    $this->tracker->setLogger($logger);
+
+    $this->tracker->setConfiguration(['indexing_order' => $indexing_order]);
+    $datasource_1 = 'test1';
+    $datasource_2 = 'test2';
+    $ids = [];
+    foreach ([$datasource_1, $datasource_2] as $num => $datasource_id) {
+      foreach ([1, 2, 3] as $raw_id) {
+        $ids[$num][] = Utility::createCombinedId($datasource_id, $raw_id);
+      }
+    }
+
+    // Make sure we start from a "clean slate".
+    $this->assertIndexingStatus(0, 0);
+    $this->assertIndexingStatus(0, 0, $datasource_1);
+    $this->assertIndexingStatus(0, 0, $datasource_2);
+
+    // Make sure tracking items as deleted, updated or indexed has no effect if
+    // none were inserted before.
+    $this->tracker->trackItemsDeleted([$ids[0][0]]);
+    $this->assertIndexingStatus(0, 0);
+    $this->tracker->trackItemsUpdated([$ids[0][0]]);
+    $this->assertIndexingStatus(0, 0);
+    $this->tracker->trackItemsIndexed([$ids[0][0]]);
+    $this->assertIndexingStatus(0, 0);
+
+    // Now, finally, actually do something sensible and insert some items.
+    $this->tracker->trackItemsInserted([$ids[0][0]]);
+    $this->tracker->trackItemsInserted([$ids[1][1]]);
+    $this->timeService->advanceTime();
+    $this->tracker->trackItemsInserted([$ids[0][2], $ids[1][0]]);
+    $this->timeService->advanceTime();
+    // Make sure re-inserting an item doesn't cause problems.
+    $this->tracker->trackItemsInserted([$ids[0][1], $ids[1][2], $ids[0][0]]);
+    $this->timeService->advanceTime();
+
+    $this->assertIndexingStatus(0, 6);
+    $this->assertIndexingStatus(0, 3, $datasource_1);
+    $this->assertIndexingStatus(0, 3, $datasource_2);
+
+    // Make sure the remaining items are returned as expected.
+    $fifo = $indexing_order === 'fifo';
+    $to_index = $this->tracker->getRemainingItems(4);
+    sort($to_index);
+    if ($fifo) {
+      $expected = [$ids[0][0], $ids[0][2], $ids[1][0], $ids[1][1]];
+    }
+    else {
+      $expected = [$ids[0][1], $ids[0][2], $ids[1][0], $ids[1][2]];
+    }
+    $this->assertEquals($expected, $to_index);
+
+    $to_index = $this->tracker->getRemainingItems(1, $datasource_1);
+    if ($fifo) {
+      $expected = [$ids[0][0]];
+    }
+    else {
+      $expected = [$ids[0][1]];
+    }
+    $this->assertEquals($expected, $to_index);
+
+    $to_index = $this->tracker->getRemainingItems(-1);
+    sort($to_index);
+    $expected = array_merge($ids[0], $ids[1]);
+    $this->assertEquals($expected, $to_index);
+
+    $to_index = $this->tracker->getRemainingItems(-1, $datasource_2);
+    sort($to_index);
+    $this->assertEquals($ids[1], $to_index);
+
+    // Make sure that tracking an unindexed item as updated will not affect its
+    // position for FIFO, but will get it to the front for LIFO. (If we do this
+    // with the item that's in front for FIFO anyways, we can use the same code
+    // in both cases.)
+    $this->tracker->trackItemsUpdated([$ids[0][0]]);
+    $this->timeService->advanceTime();
+    $to_index = $this->tracker->getRemainingItems(1, $datasource_1);
+    $this->assertEquals([$ids[0][0]], $to_index);
+
+    // Make sure calling methods with an empty $ids array doesn't blow anything
+    // up.
+    $this->tracker->trackItemsInserted([]);
+    $this->tracker->trackItemsUpdated([]);
+    $this->tracker->trackItemsIndexed([]);
+    $this->tracker->trackItemsDeleted([]);
+
+    // None of this should have changed the indexing status of any items.
+    $this->assertIndexingStatus(0, 6);
+    $this->assertIndexingStatus(0, 3, $datasource_1);
+    $this->assertIndexingStatus(0, 3, $datasource_2);
+
+    // Now, change the status of some of the items.
+    $this->tracker->trackItemsIndexed([$ids[0][0], $ids[0][1], $ids[1][0]]);
+    $this->assertIndexingStatus(3, 6);
+    $this->assertIndexingStatus(2, 3, $datasource_1);
+    $this->assertIndexingStatus(1, 3, $datasource_2);
+    $to_index = $this->tracker->getRemainingItems(-1);
+    sort($to_index);
+    $expected = [$ids[0][2], $ids[1][1], $ids[1][2]];
+    $this->assertEquals($expected, $to_index);
+
+    $this->tracker->trackItemsUpdated([$ids[0][0], $ids[0][2]]);
+    $this->timeService->advanceTime();
+    $this->assertIndexingStatus(2, 6);
+    $this->assertIndexingStatus(1, 3, $datasource_1);
+    $this->assertIndexingStatus(1, 3, $datasource_2);
+    $to_index = $this->tracker->getRemainingItems(-1);
+    sort($to_index);
+    array_unshift($expected, $ids[0][0]);
+    $this->assertEquals($expected, $to_index);
+
+    $this->tracker->trackItemsDeleted([$ids[1][0], $ids[1][2]]);
+    $this->assertIndexingStatus(1, 4);
+    $this->assertIndexingStatus(1, 3, $datasource_1);
+    $this->assertIndexingStatus(0, 1, $datasource_2);
+    $to_index = $this->tracker->getRemainingItems(-1);
+    sort($to_index);
+    // The last element of $expected is $ids[1][2], which we just deleted.
+    unset($expected[3]);
+    $this->assertEquals($expected, $to_index);
+
+    // Make sure the right items are "at the front" of the queue in each case.
+    if ($fifo) {
+      // These are the only two (remaining) items that were never indexed, so
+      // they still have their original insert time stamp and thus go first.
+      $expected = [$ids[0][2], $ids[1][1]];
+    }
+    else {
+      // We just tracked an update for both of these, so they go first.
+      $expected = [$ids[0][0], $ids[0][2]];
+    }
+    $to_index = $this->tracker->getRemainingItems(2);
+    sort($to_index);
+    $this->assertEquals($expected, $to_index);
+
+    // Some more status changes.
+    $this->tracker->trackItemsInserted([$ids[1][2]]);
+    $this->timeService->advanceTime();
+    $this->assertIndexingStatus(1, 5);
+    $this->assertIndexingStatus(1, 3, $datasource_1);
+    $this->assertIndexingStatus(0, 2, $datasource_2);
+
+    $this->tracker->trackItemsIndexed(array_merge($ids[0], $ids[1]));
+    $this->assertIndexingStatus(5, 5);
+    $this->assertIndexingStatus(3, 3, $datasource_1);
+    $this->assertIndexingStatus(2, 2, $datasource_2);
+
+    $this->tracker->trackAllItemsUpdated($datasource_1);
+    $this->timeService->advanceTime();
+    $this->assertIndexingStatus(2, 5);
+    $this->assertIndexingStatus(0, 3, $datasource_1);
+    $this->assertIndexingStatus(2, 2, $datasource_2);
+
+    $this->tracker->trackItemsIndexed([$ids[0][0]]);
+    $this->assertIndexingStatus(3, 5);
+    $this->assertIndexingStatus(1, 3, $datasource_1);
+    $this->assertIndexingStatus(2, 2, $datasource_2);
+
+    $this->tracker->trackAllItemsUpdated();
+    $this->timeService->advanceTime();
+    $this->assertIndexingStatus(0, 5);
+    $this->assertIndexingStatus(0, 3, $datasource_1);
+    $this->assertIndexingStatus(0, 2, $datasource_2);
+
+    $this->tracker->trackAllItemsDeleted($datasource_2);
+    $this->assertIndexingStatus(0, 3);
+    $this->assertIndexingStatus(0, 3, $datasource_1);
+    $this->assertIndexingStatus(0, 0, $datasource_2);
+
+    $this->tracker->trackAllItemsDeleted();
+    $this->assertIndexingStatus(0, 0);
+    $this->assertIndexingStatus(0, 0, $datasource_1);
+    $this->assertIndexingStatus(0, 0, $datasource_2);
+  }
+
+  /**
+   * Provides test data for testTracking().
+   *
+   * @return array[]
+   *   An array of argument arrays for testTracking().
+   *
+   * @see \Drupal\Tests\search_api\Kernel\BasicTrackerTest::testTracking()
+   */
+  public function trackingDataProvider() {
+    return [
+      'FIFO' => ['fifo'],
+      'LIFO' => ['lifo'],
+    ];
+  }
+
+  /**
+   * Tests whether a method properly handles exceptions.
+   *
+   * @param string $tracker_method
+   *   The method to test.
+   * @param array $args
+   *   (optional) The arguments to pass to the method.
+   * @param bool $uses_transaction
+   *   (optional) Whether the method is expected to use a transaction (and roll
+   *   it back upon encountering an exception).
+   *
+   * @dataProvider exceptionHandlingDataProvider
+   */
+  public function testExceptionHandling($tracker_method, array $args = [], $uses_transaction = FALSE) {
+    /** @var \PHPUnit_Framework_MockObject_MockObject|\Drupal\Core\Database\Connection $connection */
+    $connection = $this->getMockBuilder(Connection::class)
+      ->disableOriginalConstructor()
+      ->getMock();
+    foreach (['select', 'insert', 'update', 'delete'] as $method) {
+      $connection->method($method)->willThrowException(new \Exception());
+    }
+    $transaction = $this->getMockBuilder(Transaction::class)
+      ->disableOriginalConstructor()
+      ->getMock();
+    $rolled_back = FALSE;
+    $rollback = function () use (&$rolled_back) {
+      $rolled_back = TRUE;
+    };
+    $transaction->method('rollback')->willReturnCallback($rollback);
+    $connection->method('startTransaction')->willReturn($transaction);
+    $this->tracker->setDatabaseConnection($connection);
+
+    /** @var \PHPUnit_Framework_MockObject_MockObject|\Psr\Log\LoggerInterface $logger */
+    $logger = $this->getMock(LoggerInterface::class);
+    $log = [];
+    $logger->method('log')->willReturnCallback(function () use (&$log) {
+      $log[] = func_get_args();
+    });
+    $this->tracker->setLogger($logger);
+
+    call_user_func_array([$this->tracker, $tracker_method], $args);
+
+    $this->assertCount(1, $log);
+    $this->assertStringStartsWith('%type', $log[0][1]);
+
+    if ($uses_transaction) {
+      $this->assertEquals(TRUE, $rolled_back);
+    }
+  }
+
+  /**
+   * Provides test data for testExceptionHandling().
+   *
+   * @return array[]
+   *   An array of argument arrays for testExceptionHandling().
+   *
+   * @see \Drupal\Tests\search_api\Kernel\BasicTrackerTest::testExceptionHandling()
+   */
+  public function exceptionHandlingDataProvider() {
+    return [
+      'trackItemsInserted()' => ['trackItemsInserted', [['']], TRUE],
+      'trackItemsUpdated()' => ['trackItemsUpdated', [['']], TRUE],
+      'trackAllItemsUpdated()' => ['trackAllItemsUpdated', [], TRUE],
+      'trackItemsIndexed()' => ['trackItemsIndexed', [['']], TRUE],
+      'trackItemsDeleted()' => ['trackItemsDeleted', [], TRUE],
+      'trackAllItemsDeleted()' => ['trackAllItemsDeleted', [], TRUE],
+      'getRemainingItems()' => ['getRemainingItems'],
+      'getTotalItemsCount()' => ['getTotalItemsCount'],
+      'getIndexedItemsCount()' => ['getIndexedItemsCount'],
+      'getRemainingItemsCount()' => ['getRemainingItemsCount'],
+    ];
+  }
+
+  /**
+   * Asserts that the current tracking status is as expected.
+   *
+   * @param int $indexed
+   *   The expected number of indexed items.
+   * @param int $total
+   *   The expected total number of items.
+   * @param string|null $datasource_id
+   *   (optional) The datasource for which to check indexing status, or NULL to
+   *   check for the whole index.
+   */
+  protected function assertIndexingStatus($indexed, $total, $datasource_id = NULL) {
+    $datasource = $datasource_id ? " for datasource $datasource_id" : '';
+    $actual_indexed = $this->tracker->getIndexedItemsCount($datasource_id);
+    $this->assertEquals($indexed, $actual_indexed, "$actual_indexed items indexed$datasource, $indexed expected.");
+    $actual_total = $this->tracker->getTotalItemsCount($datasource_id);
+    $this->assertEquals($total, $actual_total, "$actual_total items tracked in total$datasource, $total expected.");
+    $remaining = $total - $indexed;
+    $actual_remaining = $this->tracker->getRemainingItemsCount($datasource_id);
+    $this->assertEquals($remaining, $actual_remaining, "$actual_remaining items remaining to be indexed$datasource, $remaining expected.");
+  }
+
+}
diff --git a/tests/src/Kernel/TestTimeService.php b/tests/src/Kernel/TestTimeService.php
new file mode 100644
index 00000000..0b7d640f
--- /dev/null
+++ b/tests/src/Kernel/TestTimeService.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace Drupal\Tests\search_api\Kernel;
+
+use Drupal\Component\Datetime\Time;
+
+/**
+ * Provides a dummy time service that can be used for testing.
+ */
+class TestTimeService extends Time {
+
+  /**
+   * The request time to return.
+   *
+   * @var int
+   *
+   * @see \Drupal\Tests\search_api\Kernel\TestTimeService::getRequestTime()
+   */
+  protected $requestTime;
+
+  /**
+   * Constructs a TestTimeService object.
+   */
+  public function __construct() {
+    $this->requestTime = time();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getRequestTime() {
+    return $this->requestTime;
+  }
+
+  /**
+   * Advances the reported request time.
+   *
+   * @param int $seconds
+   *   (optional) Number of seconds by which to advance the reported request
+   *   time.
+   *
+   * @return $this
+   */
+  public function advanceTime($seconds = 1) {
+    $this->requestTime += $seconds;
+    return $this;
+  }
+
+}
