Peewee: Fix for #1991 causes DoesNotExist error during row iteration

Created on 27 Feb 2020  路  12Comments  路  Source: coleifer/peewee

I have a rather large cross-model join query that started throwing an exception after updating from 3.9.6 to 3.13.1. This appears to be caused by the fix for #1991 .

The query is:

        for inc in (self.vulnerabilities
                        .select(fn.COALESCE(Team.guid, Vulnerability.guid).alias('guid'),
                                fn.COALESCE(Team.remedy_group, self.name).alias('name'),
                                Plugin.id,
                                fn.COUNT(Vulnerability.host_id).alias('count'),
                                fn.JSON_GROUP_ARRAY(Vulnerability.host_id).python_value(str).alias('host_ids'))
                        .join(Plugin, src=Vulnerability)
                        .join(Query, src=Vulnerability)
                        .join(Host, src=Vulnerability)
                        .join(Team, join_type=JOIN.LEFT_OUTER, on=(Team.name == team_name), src=Host)
                        .where(Query.process << INCIDENT_PROCESS_PLUGIN)
                        .where(Host.state != HOST_STATE_TERMINATED)
                        .group_by(fn.COALESCE(Team.guid, Vulnerability.guid),
                                  fn.COALESCE(Team.remedy_group, self.name),
                                  Plugin.id)
                        .order_by(fn.COALESCE(Team.guid, Vulnerability.guid),
                                  fn.COALESCE(Team.remedy_group, self.name),
                                  Plugin.id)):
            # do something

The resulting query SQL that gets logged is:

('SELECT COALESCE("t1"."guid", "t2"."guid") AS "guid", COALESCE("t1"."remedy_group", ?) AS "name", "t3"."id", COUNT("t2"."host_id") AS "count", JSON_GROUP_ARRAY("t2"."host_id") AS "host_ids" FROM "vulnerability" AS "t2" INNER JOIN "plugin" AS "t3" ON ("t2"."plugin_id" = "t3"."id") INNER JOIN "query" AS "t4" ON ("t2"."query_id" = "t4"."id") INNER JOIN "host" AS "t5" ON ("t2"."host_id" = "t5"."ip_address") LEFT OUTER JOIN "team" AS "t1" ON ("t1"."name" = (SELECT "t1"."name" FROM "team" AS "t1" WHERE (("t1"."name" = TRIM(json_extract("t5"."tags", ?))) OR ("t1"."remedy_group" = TRIM(json_extract("t5"."tags", ?)))) LIMIT ?)) WHERE ((("t2"."remediation_group_id" = ?) AND ("t4"."process" IN (?, ?))) AND ("t5"."state" != ?)) GROUP BY COALESCE("t1"."guid", "t2"."guid"), COALESCE("t1"."remedy_group", ?), "t3"."id" ORDER BY COALESCE("t1"."guid", "t2"."guid"), COALESCE("t1"."remedy_group", ?), "t3"."id"', [u'Server Operations', u'$.TechOwnerName', u'$.RemedyGroup', 1, 764, u'standard', u'compliance', u'terminated', u'Server Operations', u'Server Operations'])

The exception is:

  File "/var/task/ticketlib/remediation.py", line 232, in get_known_tickets
    Plugin.id)):
  File "/var/task/peewee.py", line 4287, in next
    self.cursor_wrapper.iterate()
  File "/var/task/peewee.py", line 4206, in iterate
    result = self.process_row(row)
  File "/var/task/peewee.py", line 7450, in process_row
    if instance not in set_keys and dest not in set_keys \
  File "/var/task/peewee.py", line 6483, in __hash__
    return hash((self.__class__, self._pk))
  File "/var/task/peewee.py", line 6374, in get_id
    return getattr(self, self._meta.primary_key.safe_name)
  File "/var/task/peewee.py", line 5382, in __get__
    for field_name in self.field_names])
  File "/var/task/peewee.py", line 4332, in __get__
    return self.get_rel_instance(instance)
  File "/var/task/peewee.py", line 4327, in get_rel_instance
    raise self.rel_model.DoesNotExist
HostDoesNotExist  

It's failing in the iterator, without any iterations of the for loop running. I am guessing I somehow have a Vulnerability with a host_id set to a value that doesn't have a corresponding PK in the Host table, but I can't even try to track that down because it's blowing up the iterator immediately.

Most helpful comment

I'll fix the tests, thanks.

All 12 comments

SELECT
  COALESCE("t1"."guid", "t2"."guid") AS "guid",
  COALESCE("t1"."remedy_group", ?) AS "name",
  "t3"."id",
  COUNT("t2"."host_id") AS "count",
  JSON_GROUP_ARRAY("t2"."host_id") AS "host_ids" 
FROM "vulnerability" AS "t2" 
INNER JOIN "plugin" AS "t3" ON ("t2"."plugin_id" = "t3"."id") 
INNER JOIN "query" AS "t4" ON ("t2"."query_id" = "t4"."id") 
INNER JOIN "host" AS "t5" ON ("t2"."host_id" = "t5"."ip_address") 
LEFT OUTER JOIN "team" AS "t1" ON ("t1"."name" = (
  SELECT "t1"."name" FROM "team" AS "t1" 
  WHERE (
    ("t1"."name" = TRIM(json_extract("t5"."tags", ?))) OR 
    ("t1"."remedy_group" = TRIM(json_extract("t5"."tags", ?)))) LIMIT ?
)) 
WHERE ((
  ("t2"."remediation_group_id" = ?) AND 
  ("t4"."process" IN (?, ?))) AND
   ("t5"."state" != ?)) 
GROUP BY 
  COALESCE("t1"."guid", "t2"."guid"), 
  COALESCE("t1"."remedy_group", ?), 
  "t3"."id" 
ORDER BY COALESCE("t1"."guid", "t2"."guid"), COALESCE("t1"."remedy_group", ?), "t3"."id"

Does one of those models have a primary key that is a foreign-key to a different table?

Is there any way you can provide a minimal way to reproduce this issue? At the very least subtract from this example until you can isolate the minimal query that reproduces the problem?

I am guessing I somehow have a Vulnerability with a host_id set to a value that doesn't have a corresponding PK in the Host table

How would this happen? The whole thing with referential integrity is that this should not occur.

Going through the stack trace, I'll show you my thoughts:

We're checking if an instance belongs to a set/dict, so we need its hash, which consists of the class and primary-key value:

  File "/var/task/peewee.py", line 6483, in __hash__
    return hash((self.__class__, self._pk))

The _pk accessor is a property that gets the value of the primary-key field. Note that it uses primary_key.safe_name. When the primary-key is also a foreign-key, this accessor will the be "object_id_name", which allows us to access the raw value of the foreign-key, rather than potentially issuing a query to resolve the foreign-key to an instance.

  File "/var/task/peewee.py", line 6374, in get_id
    return getattr(self, self._meta.primary_key.safe_name)

It looks like you've got a composite primary-key...Why do people do this?!

  File "/var/task/peewee.py", line 5382, in __get__
    for field_name in self.field_names])

One part of the composite pk is also a foreign-key?

  File "/var/task/peewee.py", line 4332, in __get__
    return self.get_rel_instance(instance)

Missing the part we need:

  File "/var/task/peewee.py", line 4327, in get_rel_instance
    raise self.rel_model.DoesNotExist

In your composite primary-key that also contains a foreign-key, try changing the foreign-key part to be the "safe-name" of the FK rather than the FK field itself:

# Change this:
primary_key = CompositeKey('host', 'other_field')

# To this:
primary_key = CompositeKey('host_id', 'other_field')

Try that out and let me know if it fixes the problem and works.

using composite primary keys ever

Active record ORMs do not work well with composite pks. Try using a regular autoincrement / uuid in the future and just put a unique constraint on the columns you need to guarantee are unique. You'll have a much better time.

@brandond - can you please take a look at my latest comment and provide additional info?

I have been able to reproduce the issue by adding the following code to tests/models.py:

class Neighbourhood(TestModel):
    name = CharField()
    city = ForeignKeyField(City)
    class Meta:
        primary_key = CompositeKey('name', 'city')

class TestCompositePKwithFK(ModelTestCase):
    @requires_models(City, Neighbourhood)
    def test_join_query(self):
        city = City.create(name='city')
        neighbourhood = Neighbourhood.create(name='n1',city=city)

        query = (Neighbourhood
          .select(
              Neighbourhood.name,
              City.name
          )
            .join(City).where(
                Neighbourhood.name == 'n1'
         ) )

        res = query.get()
        self.assertEqual(res.name,'n1')
        self.assertEqual(res.city.name, 'city')

I am not sure of what you mean with the safe-name of the FK. Replacing "city" by "city_id" in the CompositeKey declaration causes a KeyError at database creation time

You're probably going to hate me for this, but here's a subsection of the model code. I have the business logic in a subclass of RemediationGroupBase, so self.vulnerabilities hits the backref to the Vulnerability model which does indeed have a composite pk.

class RemediationGroupBase(ModelBase):
    """Peewee model class representing persistent data associated with a Tenable asset group"""
    id = IntegerField(verbose_name='Group ID', primary_key=True)
    name = CharField(verbose_name='Group Name')
    guid = CharField(verbose_name='Template GUID')
    mid_tier = CharField(verbose_name='Remedy Mid-Tier')
    process = CharField(verbose_name='Process')

    class Meta:
        without_rowid = True
        table_name = 'remediation_group'

class Vulnerability(ModelBase):
    """Peewee model class representing a single Vulnerability finding"""
    last_seen = DateTimeField(verbose_name='Last Seen Date')
    first_seen = DateTimeField(verbose_name='First Seen Date')
    plugin_text = CompressedField(verbose_name='Plugin Text', compression_level=9)
    netbios_name = CharField(verbose_name='NetBIOS Name')
    mac_address = CharField(verbose_name='MAC Address')
    repository = CharField(verbose_name='Repository')
    port = IntegerField(verbose_name='Port')
    dns_name = CharField(verbose_name='DNS Name')
    protocol = CharField(verbose_name='Protocol')
    host = ForeignKeyField(verbose_name='Host', model=Host, backref='vulnerabilities')
    plugin = ForeignKeyField(verbose_name='Plugin', model=Plugin, backref='vulnerabilities')
    remediation_group = ForeignKeyField(verbose_name='Remediation Group', model=RemediationGroupBase, backref='vulnerabilities')
    query = ForeignKeyField(verbose_name='Query ID', model=Query, backref='vulnerabilities')
    guid = CharField(verbose_name='Query Template GUID')
    patched = BooleanField(verbose_name='Patched', default=False)

    class Meta:
        without_rowid = True
        primary_key = CompositeKey('host', 'port', 'protocol', 'plugin', 'first_seen')

I'm using a composite PK because I'm bulk-loading denormalized data from a 3rd party API and just use insert().on_conflict_replace() to deduplicate rows.

I'll try replacing 'host' with 'host_id', etc in the CompositeKey and see if that makes it happier.

As @Nurbel mentioned, using the safe name causes a KeyError on table creation:

  File "/home/ec2-user/.local/lib/python2.7/site-packages/ticketlib/models.py", line 178, in <module>
    Vulnerability.create_table(safe=True)
  File "/home/ec2-user/.local/lib/python2.7/site-packages/peewee.py", line 6529, in create_table
    cls._schema.create_all(safe, **options)
  File "/home/ec2-user/.local/lib/python2.7/site-packages/peewee.py", line 5679, in create_all
    self.create_table(safe, **table_options)
  File "/home/ec2-user/.local/lib/python2.7/site-packages/peewee.py", line 5534, in create_table
    self.database.execute(self._create_table(safe=safe, **options))
  File "/home/ec2-user/.local/lib/python2.7/site-packages/peewee.py", line 5492, in _create_table
    for field_name in meta.primary_key.field_names]
KeyError: 'plugin_id'

Thanks for the info - I'll get to the bottom of this.

I'll give that change a try, but the tests have all failed?

I'll fix the tests, thanks.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

kadnan picture kadnan  路  3Comments

alexpantyukhin picture alexpantyukhin  路  5Comments

ezk84 picture ezk84  路  4Comments

GMaxera picture GMaxera  路  4Comments

mmongeon-aa picture mmongeon-aa  路  4Comments