Skip to content

Support custom ID loading and native multi-tenancy #1748

Description

@Amnesthesia

Is your feature request related to a problem? Please describe.
We use Apartment (unfortunately), and we are currently using the workaround in the docs where we have one index per model per tenant. This really doesn't scale well at all, with N tenants in the hundreds and M models in the dozens, we end up with N * M models, and this becomes very heavy for elasticsearch.

We have tried implementing this using the current docs for search_document_id, but we run into problems around reindexing cross-tenant, and records that exist in another tenant being cleared out. There's no good way for us to reindex one account, switch schema, then reindex the next account, and keep the documents in elasticsearch.

To do that, we currently need to hook into Searchkick internals, and then:

  1. find or create a new index
  2. index for the current tenant
  3. switch tenant
  4. resume indexing on the next tenant on the same new index (can be done with resume = true in Searchkick internals)

But Searchkick seems to assume numeric sequential IDs when resuming indexes

def resume_relation(relation)
    if relation.respond_to?(:primary_key)
      # use total docs instead of max id since there's not a great way
      # to get the max _id without scripting since it's a string
      where = relation.arel_table[relation.primary_key].gt(index.total_docs)
      relation = relation.where(where)
    else
      raise Error, "Resume not supported for Mongoid"
    end
  end

Since we have switched tenant, we now have a new relation on a different schema, and we're also not using numeric IDs but UUIDs, which aren't sequential anyways and cant be queries with .gt(..). Therefore we had to monkey patch this to just return the fresh new relation on the schema:

# Normally, when Searchkick tries to resume and index,
# it will look for ids greater than the last id it has in
# the index. This doesnt work for uuids, and it doesnt work
# in our multi-tenant setup since each tenant has its own
# set of ids. So we just disable this behavior
# and use resume: true to continue indexing from another tenant
Searchkick::RelationIndexer.define_method(:resume_relation) do |relation|
  relation
end

Next, we found that we had to manually parse the ID back from GlobalID in the response unless we use load: false:

# This overrides Searchkick's hit parsing to convert GlobalID back to model IDs
# to allow us to store unique IDs across tenants in ElasticSearch
Searchkick::Results.define_method(:hits) do
  if error
    raise Error, "Query error - use the error method to view it"
  else
    @response["hits"]["hits"].map do |hit|
      if parsed = GlobalID.parse(hit["_id"])
        if parsed.model_id =~ /^\d+$/
          hit["_id"] = parsed.model_id.to_i
        else
          hit["_id"] = parsed.model_id
        end
      end
      hit
    end
  end
end

Describe the solution you'd like
What we would like, would be to use GlobalID (or another custom identifier) as the identifier for each record in the index, and instead use a single index per model, tagged with the tenant in search_data. We can achieve most of that with Searchkick today, but what's missing is:

  1. Indexing without resetting the index when switching tenant
  2. Loading records in a custom way, e.g using a different column than the primary key, or using GlobalID

For example, a custom load method for loading a list of IDs:

def self.searchkick_load(relation, ids)
  relation.where(id: ids.map { |global_id| GlobalID.parse(global_id).model_id })
end

A customizable method for retrieving records for reindexing that allows custom batching and cross-tenant, e.g:

def self.searchkick_reindex_relation
  Apartment::Tenant.each do |tenant|
    self.by_tenant(tenant).in_batches do |batch_relation|
       yield batch_relation
    end
  end
end

A customizable method for checking if IDs exist in the database before deletion:

def self.searchkick_exists?(ids)
  existing = GlobalID::Locator.locate_many(ids).map(&:to_gid_param)
  ids.map { |id| id.in?(existing) }
end

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions