There are other issues about this, but I don't know if they describe the same or a separate problem.
But I think I can provide a clear description what is happening and why, so I will.

See also #1233426: Dirty and queued items don't get removed from the tracking table when deleted

Symptom

I have some code that changes node bundles.
Simplified:

$node = node_load(123);
assert($node->type === 'some_type');
$node->type = 'other_type';
node_save($node);

This triggers search_api_entity_update(), which calls _search_api_entity_datasource_bundle_change(), which calls either $controller->trackItemDelete() or $controller->trackItemInsert().

The trackItemInsert() calls db_insert(), which causes a PDOException if the key already exists, e.g.

Duplicate entry '105631-32' for key 'PRIMARY'

in

INSERT INTO {search_api_item} (item_id, index_id, changed) VALUES (:db_insert_placeholder_0, :db_insert_placeholder_1, :db_insert_placeholder_2)

Stack trace:

18: DatabaseStatementBase->execute() (Array, 3 elements)
17: DatabaseConnection->query() (Array, 4 elements)
16: InsertQuery_mysql->execute() (Array, 1 element)
15: SearchApiAbstractDataSourceController->trackItemInsert() (Array, 3 elements)
14: SearchApiEntityDataSourceController->trackItemInsert() (Array, 3 elements)
13: _search_api_entity_datasource_bundle_change() (Array, 5 elements)
12: search_api_entity_update() (Array, 3 elements)
11: call_user_func_array() (Array, 2 elements)
10: module_invoke_all() (Array, 4 elements)
9: node_save() (Array, 2 elements)
[..]

Explanation

So, why would the table already contain an entry with this key?
I'd say, in general it won't.

But it could happen that
1. I try node_save($node). It fails. But it still writes to search_api_item for some reason.
2. I fix my code.
3. I run node_save($node) again. this time it works. But the entry in search_api_item might already exist from previous attempt.

Or maybe there is a problem if the index tracks both the old and new node type?

Solution

Use something with REPLACE INTO. db_merge() should do the job, right?
http://stackoverflow.com/questions/5825337/help-with-db-query-in-drupal-...
But this is one query for every item. Maybe does not scale?

I will update this issue when I find out more.

Comments

donquixote created an issue. See original summary.

donquixote’s picture

Oh I think I really have the explanation.

Create a search index 'my_index'.
Enable indexing for node type 'some_type' (but not 'other_type').
Create some nodes and index them.

-> Now the search_api_item table contains entries for 'some_type'.

And now..
Create node type 'other_type'.
Enable indexing of 'other_type', disable indexing of 'some_type'.

-> Now the search_api_item table now still contains entries for 'some_type'.

Convert nodes from 'some_type' to 'other_type'.

-> Now search_api will try to insert the same items as 'other_type', because it thinks that entries for 'some_type' do not exist.

donquixote’s picture

Here is a possible fix. Maybe I will produce a patch..

abstract class SearchApiAbstractDataSourceController implements SearchApiDataSourceControllerInterface {
  [..]

  /**
   * {@inheritdoc}
   */
  public function trackItemInsert(array $item_ids, array $indexes) {
    if (!$this->table || $item_ids === array()) {
      return;
    }

    $index_ids = [];
    foreach ($indexes as $index) {
      $this->checkIndex($index);
      // Prevent the array key to be converted to integer.
      $index_ids['_' . $index->id] = $index->id;
    }

    // Since large amounts of items can overstrain the database, only add items
    // in chunks.
    foreach (array_chunk($item_ids, 1000) as $chunk) {

      $q = db_select($this->table, 't');
      $q->condition($this->itemIdColumn, $chunk);
      $q->condition($this->indexIdColumn, $index_ids);
      $q->addField('t', $this->itemIdColumn, 'itemId');
      $q->addField('t', $this->indexIdColumn, 'indexId');
      $q->addField('t', $this->changedColumn, 'changed');

      // PHP array keys can be unreliable in some cases..
      $existing = [];
      foreach ($q->execute() as $row) {
        // Prevent the array keys to be converted to integer.
        $existing['_' . $row->itemId]['_' . $row->indexId] = TRUE;
      }

      $insert = db_insert($this->table)
        ->fields(array($this->itemIdColumn, $this->indexIdColumn, $this->changedColumn));

      foreach ($chunk as $item_id) {
        foreach ($index_ids as $index_id) {
          if (!empty($existing['_' . $item_id]['_' . $index_id])) {
            $q = db_update($this->table);
            $q->condition($this->indexIdColumn, $index_id);
            $q->condition($this->itemIdColumn, $item_id);
            $q->fields([$this->changedColumn => 1]);
            $q->execute();
          }
          else {
            $insert->values(array(
              $this->itemIdColumn => $item_id,
              $this->indexIdColumn => $index_id,
              $this->changedColumn => 1,
            ));
          }
        }
      }
      $insert->execute();
    }
  }
donquixote’s picture

"Prevent the array key to be converted to integer."
Why am I doing this?

I think to remember some experiment where different strings would become the same array key, like this:

assert($a !== $b && [$a => TRUE] !== [$b => TRUE]);

Now I cannot find any examples anymore to reproduce this.

EDIT
https://3v4l.org/2XYtc -> behaves as it should.
Maybe this problem only exists when converting from float.

drunken monkey’s picture

Status: Active » Postponed (maintainer needs more info)

Enable indexing of 'other_type', disable indexing of 'some_type'.

-> Now the search_api_item table now still contains entries for 'some_type'.

I'm pretty sure we forbid editing of the indexed bundles after creating the index, for exactly this reason? How are you doing that?
If you've patched your module to allow this, the patch obviously doesn't implement it properly.
If you haven't patched it and it's still possible, that's the actual bug here.

Regarding your suggestion in #3, we're doing something very similar in Drupal 8: see here.
However, if there's no valid way to trigger the problem here, I'm not sure we should sacrifice the performance and add this.

donquixote’s picture

I'm pretty sure we forbid editing of the indexed bundles after creating the index, for exactly this reason? How are you doing that?

This is a complicated project and I don't know exactly why this happened.
My assumption is that it was coming from a feature module somewhere.
Maybe the "forbid editing" only applies to the form, but not to the feature. This would mean the feature needs a hook_update_N() that wipes the search index?

Or if we want to fix this in search_api:
One solution could be to separate the conf variable from the actual state.
- The variable tells us which bundles *should* be indexed.
- A value in cache or elsewhere, possibly even in the search index itself (?), tells us which bundles were actually indexed last time.

Or maybe this:
Catch the exception, tell the user to clear the search index. (if this helps)

donquixote’s picture

I just tried: The bundle settings can be easily changed with features, by changing feature code and doing drush fr.

drunken monkey’s picture

Status: Postponed (maintainer needs more info) » Closed (works as designed)

I just tried: The bundle settings can be easily changed with features, by changing feature code and doing drush fr.

Sure they can – it just shouldn't be done. Or, if you do it, write an update hook to deal with it properly (as you say). It's not our responsibility in the Search API to deal with people changing settings we defined as "unchangeable".