When indexing datetime field, the code gets the value of field without timezone (which is storage timezone, but gets ignored) and then it converts it to timestamp, by using strtotime(), which then uses default timezone of system.

Most easiest way to reproduce this is:
- Outside UTC timezone (user with other timezone should be enough)
- Create entity with datetime field, enter datetime (which in form will be in users timezone)
- Index that entity
- Create view that lists entities of given type
- Add filter for the (indexed) field where time should be <= 'now' so the field represents publishing date
- Modify the field to check if the filter would work correctly, you should be having offset without the fix.

Now to go a bit further from that, here's the idea made with code:

$dst = defined('DATETIME_STORAGE_TIMEZONE') ? DATETIME_STORAGE_TIMEZONE : 'UTC';

$datetime = '2018-02-18T22:30:00';
$datetime2 = $datetime . '+0200';
$datetime3 = $datetime . '+0300';

echo "Default timezone: " . date_default_timezone_get() . PHP_EOL;

echo "With strtotime:" . PHP_EOL;
echo strtotime($datetime3) . PHP_EOL;
echo strtotime($datetime2) . PHP_EOL;
echo strtotime($datetime);
echo " <- previous and this is same." . PHP_EOL;

echo "With Datetime without timezone:" . PHP_EOL;
$d = new Datetime($datetime3);
echo $d->getTimestamp() . " " . $d->getTimezone()->getName() . PHP_EOL;
$d = new Datetime($datetime2);
echo $d->getTimestamp() . " " . $d->getTimezone()->getName() . PHP_EOL;
$d = new Datetime($datetime);
echo $d->getTimestamp() . " " . $d->getTimezone()->getName();
echo " <- Timestamp is correct only if timezone is noted." . PHP_EOL;

echo "With Datetime with timezone:" . PHP_EOL;
$timezone = new DateTimeZone($dst);
$d = new Datetime($datetime3, $timezone);
echo $d->getTimestamp() . " " . $d->getTimezone()->getName() . PHP_EOL;
$d = new Datetime($datetime2, $timezone);
echo $d->getTimestamp() . " " . $d->getTimezone()->getName() . PHP_EOL;
$d = new Datetime($datetime, $timezone);
echo $d->getTimestamp() . " " . $d->getTimezone()->getName();
echo " <- Timestamp is correct always." . PHP_EOL;

Example output would be:

Europe/Helsinki
With strtotime:
1518982200
1518985800
1518985800 <- previous and this is same.
With Datetime without timezone:
1518982200 +03:00
1518985800 +02:00
1518985800 Europe/Helsinki <- Timestamp is correct only if timezone is noted.
With Datetime with timezone:
1518982200 +03:00
1518985800 +02:00
1518993000 UTC <- Timestamp is correct always.

Comments

Dropa created an issue. See original summary.

dropa’s picture

Status: Active » Needs review
StatusFileSize
new760 bytes

And here would be proposed solution.

dropa’s picture

Title: Datetime is indexed with system timezone » Datetime is indexed with system timezone (not storage timezone/UTC)
drunken monkey’s picture

Thanks a lot for reporting this problem and providing such a detailed analysis!
Let me first state that timezones are aweful. I think we can all agree on that.

Then, as it is often the case with timezones, I'm not sure I completely understand the problem.
First, it seems this would only concern cases where you want to index a date string as a date, right? I.e., the usual use case of indexing a timestamp (e.g., "Authored on") works correctly regardless?

Then it would seem your solution only works if a time zone is explicitly given in the string, right? If there is no timezone information in the date string, I'd expect using the system's default timezone would be the expected behavior, but (unless I'm mistaken) with your patch this would be parsed using UTC instead, arriving at the wrong timestamp after all. (A use case which currently works – again, unless I'm mistaken.)

Finally, the question would be whether the timestamp we filter by is actually always the correct one. However, I guess that is indeed the case, so not an issue here.

Also, regardless of all other concerns, it would seem to me like this conversion should actually happen in \Drupal\search_api\Plugin\search_api\data_type\DateDataType::getValue() (to be added), not in the backend plugin. Not sure why we didn't change that when overhauling the data type system for D8, probably just an oversight.

dropa’s picture

StatusFileSize
new18.88 KB
new6.52 KB
new8.52 KB
new8.45 KB
new6.46 KB
new7.14 KB

So the problem is indeed with datetime typed fields.

Here I have this entity, which has Date field publish_date

Now, it's settings will look like this:
field settings

Then I'll index the given field:
index

Then let's see the database.

Entity create & change times are in timestamp form,
entity times

But the Publish date actually is in string form
field time

Now lets see what index looks like

Changed date looks identical:
index times

But now here's the trick, publish date has been converted to timestamp:
index field time

Now the thing here is that like in my example code, the Date field uses default storage timezone, which it tells to be UTC, but to actually use the same constant in SAPI would require dependency for the module that provides the field.

For example \Drupal\Component\Datetime\DateTimePlus::createFromTimestamp states following:

The timezone of a timestamp is always UTC. The timezone for a
timestamp indicates the timezone used by the format() method.

So that's why my patch forces UTC in the backend.

The problem that exists in current version is that if system timezone is different than UTC, following happens:
- user enters time (in his/her timezone)
- datetime gets stored in the backend in UTC timezone
- index fetches data from database
- passes data to strtotime()
- strtotime() thinks we got string in system timezone
- now we indexed value that's as much different from the original as the system timezone differs from UTC

Note that whenever datetime is shown to user, it's prepared first with the default timezone of Drupal or the timezone of user itself.

Also note that when I'm referring to system, I'm talking about the machine where Drupal runs.

dropa’s picture

Yet to mention that current code is this:

      case 'date':
        // Timestamp is numeric. it will be returned as is.
        if (is_numeric($value) || !$value) {
          return 0 + $value;
        }
        // String will be converted to timestamp, timezone gets applied.
        return strtotime($value);

And documentation of strtotime quite explains it shorter than I do:

Each parameter of this function uses the default time zone unless a time zone is specified in that parameter. Be careful not to use different time zones in each parameter unless that is intended. See date_default_timezone_get() on the various ways to define the default time zone.

drunken monkey’s picture

StatusFileSize
new4.53 KB
new6.12 KB

Wow, thanks again for the detailed explanation! I think I now got it, and you're right, your solution makes perfect sense.
I guess this would fail if a date string in the DB were without timezone information but not in UTC – but that seems less likely than the problem we have currently. While further feedback from others would be great, I think we should go ahead with this.

However, as mentioned in #4, in my opinion all of this logic belongs into the data type plugin, not the backend plugin. So, moving it there, along with your improvements. I'm also adding test coverage, both Unit tests for the new method and a Kernel test covering the whole date indexing logic in the DB backend. Would be great if you could review those to see whether they make sense. (They do successfully pass and fail when they should, though, so doesn't seem too far off.)

Status: Needs review » Needs work

The last submitted patch, 7: 2945870-7--db_backend_date_indexing.patch, failed testing. View results

dropa’s picture

In my eyes, if any date is stored in database in other timezone than UTC, then it would be developer level mistake. Any user input should be converted anyway.

When date is stored in other timezone than UTC, it's not easy to create filter where date is limited by time (say expiration date or publishing date, so the view would be cutting results from either way from current time), and this is where I met this issue. When stored correctly in UTC, no matter where user, backend or database is located, filtering view by current time will give you correct results.

I tested your patch with Views, as well the Unit test, and can confirm them working. I was unable to run the Kernel test, but didn't find anything suspicious there (phpcs did with DrupalPractice standard though, guess it does not support list()).

All in all, seems good to me.

drunken monkey’s picture

Status: Needs work » Needs review
StatusFileSize
new474 bytes
new6.59 KB

Hm, OK, that's unfortunate – I can't reproduce this test fail locally, but the test bot seems to insist.
Trying a debug patch to maybe get to the bottom of this.

drunken monkey’s picture

Status: Needs review » Needs work

The last submitted patch, 11: 2945870-11--db_backend_date_indexing.patch, failed testing. View results

drunken monkey’s picture

Status: Needs work » Needs review
StatusFileSize
new1.25 KB
new6.54 KB

Huh.
I have to admit, I'm at a bit of a loss here. Can anyone reproduce the fail locally?

@ Dropa: Why were you "unable to run the Kernel test"? What did you try, what happened?

drunken monkey’s picture

Status: Needs review » Needs work
dropa’s picture

Looking at the error for example in here: https://www.drupal.org/pift-ci-job/909915 as it says Table 'jenkins_drupal8_contrib_patches_24770.search_api_db_database_search_index_1' doesn't exist:

and my own test says

Table
    &#039;drupal.search_api_db_database_search_index_1&#039; doesn&#039;t
    exist:

In my eyes that actually means the simpletest is trying to locate the table from the actual (real and live) database.

That would sound a bit dangerous thought, but I'm looking whether that is the case.

I think you could check whether you accidentally have search index names "database_search_index_1" in your system that you're running tests on.

dropa’s picture

Edit: Wrong analysis removed :)

dropa’s picture

Now that I took a look on how other tests had made queries to database, they have surrounded table with {} marks, so apparently it is meant to be executed in real database.

Added curlys to table, removed debugging lines and changed $excepted to $expected

dropa’s picture

Status: Needs work » Needs review

  • drunken monkey committed bcb649c on 8.x-1.x authored by Dropa
    Issue #2945870 by Dropa, drunken monkey: Fixed timezone of indexed date...
drunken monkey’s picture

Status: Needs review » Fixed

Awesome, thanks for debugging that! You're right, silly mistake on my part – good that you found it.
You forgot to include DateDataTypeTest again in your patch, but other than that it looks perfect now.
Committed.
Thanks a lot again for your contribution!

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.

bburg’s picture

Any thoughts on a new release? I believe I am running into this on 8.x-1.9.

Disregard. This should be fixed in 1.9, just need to re-index.

bburg’s picture

Ok,

Follow up from above. I am still getting the wrong dates. It seems that Search API is saving the localized timestamps, and then later pulling them out of the database as UTC. Should the hard-coded 'UTC' in DateDataType

$timezone = new \DateTimezone('UTC');

Instead be?

    $default_timezone = $config
      ->get('timezone.default');
    $timezone = new \DateTimezone($default_timezone);
    $date = new \Datetime($value, $timezone);

Even with this modification, I'm getting incorrect times. It seems stored in the index as the correct UTC value, but being pulled and rendered as the local time, but re-interpreted as UTC.

Edit: Disregard all this, I think my logged in user was on UTC, but the site used local time.