Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve tile error handling #3104

Merged
merged 11 commits into from Oct 10, 2018
5 changes: 4 additions & 1 deletion CHANGES.md
Expand Up @@ -7,7 +7,10 @@ Change Log

* Add simple WCS "clip and ship" functionality for WMS layers with corresponding a WCS endpoint and coverage.
* Fixed problems canceling drag-and-drop when using some web browsers.
* Fixed a bug that created a period where no data is shown at the end of a time-varying CSV.
* Fixed a bug that created a time period where no data is shown at the end of a time-varying CSV.
* Fixed a bug that could cause endless tile requests with certain types of incorrect server responses.
* Fixed a bug that could cause endless region tile requests when loading a CSV with a time column where none of the column values could actually be interpreted as a time.
* Added automatic retry with jittered, exponential backoff for tile requests that result in a 5xx HTTP status code. This is especially useful for servers that return 503 or 504 under load. Previously, TerriaJS would frequently disable the layer and hit the user with an error message when accessing such servers.
* Upgraded to Cesium v1.50.

### v6.1.4
Expand Down
7 changes: 6 additions & 1 deletion lib/Map/TableColumn.js
Expand Up @@ -496,7 +496,12 @@ TableColumn.convertToDates = function(goodValues, subtype) {
try {
results = goodValues.map(function(v) {
if (defined(v)) {
return dateParsers(v);
const parsed = dateParsers(v);
if (!isFinite(parsed[0]) || !defined(parsed[1]) || !isFinite(parsed[1].dayNumber) || !isFinite(parsed[1].secondsOfDay)) {
return [undefined, undefined];
} else {
return parsed;
}
} else {
return [undefined, undefined];
}
Expand Down
6 changes: 4 additions & 2 deletions lib/Map/TableStructure.js
Expand Up @@ -451,8 +451,10 @@ function calculateFinishDatesFromStartDates(startJulianDates, localDefaultFinalD
var n = sortedUniqueJulianDates.length;
if (n > 1) {
finalDurationSeconds = JulianDate.secondsDifference(sortedUniqueJulianDates[n - 1], sortedUniqueJulianDates[0]) / (n - 1);
endDates.push(JulianDate.addSeconds(sortedUniqueJulianDates[n - 1], finalDurationSeconds, new JulianDate()));
} else {
endDates.push(undefined);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kring Is this the right thing to do for n === 1?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😬 I think so. The way endDates is used below, it looks like there has to be something added, or the indices will be wrong.

}
endDates.push(JulianDate.addSeconds(sortedUniqueJulianDates[n - 1], finalDurationSeconds, new JulianDate()));
}

var result = indices.map(function(sortedIndex) {
Expand Down Expand Up @@ -584,7 +586,7 @@ function createClock(timeColumn, tableStructure) {
availabilityCollection.addInterval(availability);
});
if (!defined(timeColumn._clock)) {
if (!availabilityCollection.start.equals(Iso8601.MINIMUM_VALUE)) {
if (availabilityCollection.length > 0 && !availabilityCollection.start.equals(Iso8601.MINIMUM_VALUE)) {
var startTime = availabilityCollection.start;
var stopTime = availabilityCollection.stop;
var totalSeconds = JulianDate.secondsDifference(stopTime, startTime);
Expand Down
215 changes: 152 additions & 63 deletions lib/Models/ImageryLayerCatalogItem.js

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions lib/Models/setClockCurrentTime.js
Expand Up @@ -40,6 +40,10 @@ function _setTimeIfInRange(clock, timeToSet, stopTime)
*/
function setClockCurrentTime(clock, initialTimeSource, stopTime)
{
if (!defined(clock)) {
return;
}

// This is our default. Start at the nearest instant in time.
var now = JulianDate.now();
_setTimeIfInRange(clock, now, stopTime);
Expand Down
1 change: 1 addition & 0 deletions lib/ReactViews/Notification/notification-window.scss
Expand Up @@ -20,6 +20,7 @@
pre{
display: block;
line-height: 2;
white-space: pre-wrap;
}
}

Expand Down
3 changes: 2 additions & 1 deletion package.json
Expand Up @@ -68,11 +68,12 @@
"react-responsive": "^5.0.0",
"resolve": "^1.1.7",
"resolve-url-loader": "^2.0.2",
"retry": "^0.12.0",
"sass-loader": "^6.0.3",
"simple-statistics": "^4.1.0",
"string-replace-webpack-plugin": "^0.1.3",
"style-loader": "^0.19.1",
"terriajs-cesium": "1.50.0",
"terriajs-cesium": "1.50.1",
"terriajs-html2canvas": "1.0.0-alpha.12-terriajs-1",
"urijs": "^1.18.12",
"url-loader": "^0.5.7",
Expand Down
4 changes: 3 additions & 1 deletion test/Models/CesiumSpec.js
Expand Up @@ -47,7 +47,9 @@ describeIfSupported('Cesium Model', function() {

spyOn(terria.tileLoadProgressEvent, 'raiseEvent');

var cesiumWidget = new CesiumWidget(container);
var cesiumWidget = new CesiumWidget(container, {
imageryProvider: new TileCoordinatesImageryProvider()
});

spyOn(cesiumWidget.screenSpaceEventHandler, 'setInputAction');

Expand Down
232 changes: 226 additions & 6 deletions test/Models/ImageryLayerCatalogItemSpec.js
@@ -1,12 +1,21 @@
'use strict';

/*global require,beforeEach*/
var JulianDate = require('terriajs-cesium/Source/Core/JulianDate');
var TimeIntervalCollection = require('terriajs-cesium/Source/Core/TimeIntervalCollection');
var TimeInterval = require('terriajs-cesium/Source/Core/TimeInterval');
const CesiumEvent = require('terriajs-cesium/Source/Core/Event');
const ImageryLayer = require('terriajs-cesium/Source/Scene/ImageryLayer');
const ImageryProvider = require('terriajs-cesium/Source/Scene/ImageryProvider');
const ImageryState = require('terriajs-cesium/Source/Scene/ImageryState');
const JulianDate = require('terriajs-cesium/Source/Core/JulianDate');
const pollToPromise = require('../../lib/Core/pollToPromise');
const RequestErrorEvent = require('terriajs-cesium/Source/Core/RequestErrorEvent');
const Resource = require('terriajs-cesium/Source/Core/Resource');
const runLater = require('../../lib/Core/runLater');
const TimeIntervalCollection = require('terriajs-cesium/Source/Core/TimeIntervalCollection');
const TimeInterval = require('terriajs-cesium/Source/Core/TimeInterval');
const when = require('terriajs-cesium/Source/ThirdParty/when');

var Terria = require('../../lib/Models/Terria');
var ImageryLayerCatalogItem = require('../../lib/Models/ImageryLayerCatalogItem');
const Terria = require('../../lib/Models/Terria');
const ImageryLayerCatalogItem = require('../../lib/Models/ImageryLayerCatalogItem');

describe('ImageryLayerCatalogItem', function() {

Expand Down Expand Up @@ -113,4 +122,215 @@ describe('ImageryLayerCatalogItem', function() {
});
});

});
describe('tile error handling', function() {
const image = document.createElement('img');
image.src = 'images/blank.png';

let terria;
let catalogItem;
let imageryProvider;
let globeOrMap;
let imagery;
let imageryLayer;

beforeEach(function() {
terria = {
error: new CesiumEvent()
};
catalogItem = {
terria: terria
};
imageryProvider = {
requestImage: function(x, y, level) {
return ImageryProvider.loadImage(this, 'images/blank.png');
},
errorEvent: new CesiumEvent()
};
globeOrMap = {
terria: terria,
addImageryProvider: function(options) {
options.imageryProvider.errorEvent.addEventListener(options.onLoadError);
return new ImageryLayer(options.imageryProvider);
},
isImageryLayerShown: function() {
return true;
}
};
imagery = {
level: 0,
x: 0,
y: 0
};

terria.currentViewer = globeOrMap;

imageryLayer = ImageryLayerCatalogItem.enableLayer(catalogItem, imageryProvider, 1.0, 0, globeOrMap);
});

function failLoad(statusCode, times) {
return spyOn(Resource.prototype, 'fetchImage').and.callFake(function(preferBlob) {
if (times > 0) {
--times;
if (preferBlob) {
return when.reject(new RequestErrorEvent(statusCode, 'bad', []));
} else {
return when.reject(image);
}
} else {
return when.resolve(image);
}
});
}

it('ignores errors in disabled layers', function(done) {
spyOn(globeOrMap, 'isImageryLayerShown').and.returnValue(false);
const fetchImage = failLoad(503, 10);

imageryLayer._requestImagery(imagery);

pollToPromise(function() {
return imagery.state === ImageryState.FAILED;
}).then(function() {
expect(fetchImage.calls.count()).toEqual(1);
}).then(done).otherwise(done.fail);
});

it('retries images that fail with a 503 error', function(done) {
const fetchImage = failLoad(503, 2);

imageryLayer._requestImagery(imagery);

pollToPromise(function() {
return imagery.state === ImageryState.RECEIVED;
}).then(function() {
expect(fetchImage.calls.count()).toEqual(4);
}).then(done).otherwise(done.fail);
});

it('eventually gives up on a tile that only succeeds when loaded via blob', function(done) {
const fetchImage = spyOn(Resource.prototype, 'fetchImage').and.callFake(function(preferBlob) {
if (preferBlob) {
return runLater(function() {
return image;
});
} else {
return runLater(function() {
return when.reject(image);
});
}
});

imageryLayer._requestImagery(imagery);

pollToPromise(function() {
return imagery.state === ImageryState.FAILED;
}).then(function() {
expect(fetchImage.calls.count()).toBeGreaterThan(5);
}).then(done).otherwise(done.fail);
});

it('ignores any number of 404 errors if treat404AsError is false', function(done) {
const fetchImage = failLoad(404, 100);
catalogItem.treat404AsError = false;

const tiles = [];
for (let i = 0; i < 20; ++i) {
tiles[i] = {
level: 20,
x: i,
y: i
};
imageryLayer._requestImagery(tiles[i]);
}

pollToPromise(function() {
let result = true;
for (let i = 0; i < tiles.length; ++i) {
result = result && tiles[i].state === ImageryState.FAILED;
}
return result;
}).then(function() {
expect(fetchImage.calls.count()).toEqual(tiles.length * 2);
}).then(done).otherwise(done.fail);
});

it('ignores any number of 403 errors if treat403AsError is false', function(done) {
const fetchImage = failLoad(403, 100);
catalogItem.treat403AsError = false;

const tiles = [];
for (let i = 0; i < 20; ++i) {
tiles[i] = {
level: 20,
x: i,
y: i
};
imageryLayer._requestImagery(tiles[i]);
}

pollToPromise(function() {
let result = true;
for (let i = 0; i < tiles.length; ++i) {
result = result && tiles[i].state === ImageryState.FAILED;
}
return result;
}).then(function() {
expect(fetchImage.calls.count()).toEqual(tiles.length * 2);
}).then(done).otherwise(done.fail);
});

it('doesn\'t disable the layer after only five 404s if treat404AsError is true', function(done) {
const fetchImage = failLoad(404, 100);
catalogItem.treat404AsError = true;
catalogItem.isShown = true;

const tiles = [];
for (let i = 0; i < 5; ++i) {
tiles[i] = {
level: 20,
x: i,
y: i
};
imageryLayer._requestImagery(tiles[i]);
}

pollToPromise(function() {
let result = true;
for (let i = 0; i < tiles.length; ++i) {
result = result && tiles[i].state === ImageryState.FAILED;
}
return result;
}).then(function() {
expect(fetchImage.calls.count()).toEqual(tiles.length * 2);
expect(catalogItem.isShown).toBe(true);
}).then(done).otherwise(done.fail);
});

it('disables the layer after six 404s if treat404AsError is true', function(done) {
const fetchImage = failLoad(404, 100);
catalogItem.treat404AsError = true;
catalogItem.isShown = true;

const tiles = [];
for (let i = 0; i < 6; ++i) {
tiles[i] = {
level: 20,
x: i,
y: i
};
imageryLayer._requestImagery(tiles[i]);
}

pollToPromise(function() {
let result = true;
for (let i = 0; i < tiles.length; ++i) {
result = result && tiles[i].state === ImageryState.FAILED;
}
return result;
}).then(function() {
expect(fetchImage.calls.count()).toEqual(tiles.length * 2);
expect(catalogItem.isShown).toBe(false);
}).then(done).otherwise(done.fail);
});
});
});