Issue #2862 Implements locks on case creation.
Change-Id: Ic0975b699e54b30f152422782e11ac04c310cb1b Former-commit-id:cfea0489d7
[formerly b10c78a4f3cb7abb265d8c383c6bc2912d166720] Former-commit-id:5570261d74
This commit is contained in:
parent
69c6217ae9
commit
16fab01b8b
10 changed files with 928 additions and 57 deletions
|
@ -27,8 +27,13 @@ import java.io.IOException;
|
|||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Calendar;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Timer;
|
||||
import java.util.TimerTask;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
|
||||
|
@ -59,15 +64,22 @@ import org.eclipse.swt.widgets.Shell;
|
|||
import com.raytheon.uf.common.archive.config.ArchiveConfigManager;
|
||||
import com.raytheon.uf.common.archive.config.ArchiveConstants;
|
||||
import com.raytheon.uf.common.archive.config.DisplayData;
|
||||
import com.raytheon.uf.common.dataquery.requests.SharedLockRequest;
|
||||
import com.raytheon.uf.common.dataquery.requests.SharedLockRequest.RequestType;
|
||||
import com.raytheon.uf.common.dataquery.responses.SharedLockResponse;
|
||||
import com.raytheon.uf.common.status.IUFStatusHandler;
|
||||
import com.raytheon.uf.common.status.UFStatus;
|
||||
import com.raytheon.uf.common.status.UFStatus.Priority;
|
||||
import com.raytheon.uf.common.time.util.ITimer;
|
||||
import com.raytheon.uf.common.time.util.TimeUtil;
|
||||
import com.raytheon.uf.common.util.FileUtil;
|
||||
import com.raytheon.uf.viz.core.VizApp;
|
||||
import com.raytheon.uf.viz.core.exception.VizException;
|
||||
import com.raytheon.uf.viz.core.requests.ThriftClient;
|
||||
import com.raytheon.viz.ui.dialogs.CaveSWTDialog;
|
||||
|
||||
/**
|
||||
* This class performs the desired type of case creation and display a
|
||||
* This class performs the desired type of case creation and displays a
|
||||
* progress/status message dialog.
|
||||
*
|
||||
* <pre>
|
||||
|
@ -81,7 +93,8 @@ import com.raytheon.viz.ui.dialogs.CaveSWTDialog;
|
|||
* archive and category directory and
|
||||
* implementation of compression.
|
||||
* Oct 08, 2013 2442 rferrel Remove category directory.
|
||||
* Feb 04, 2013 2270 rferrel Move HDF files to parent's directory.
|
||||
* Feb 04, 2014 2270 rferrel Move HDF files to parent's directory.
|
||||
* Apr 03, 2014 2862 rferrel Logic for shared locking of top level directories.
|
||||
*
|
||||
* </pre>
|
||||
*
|
||||
|
@ -91,12 +104,12 @@ import com.raytheon.viz.ui.dialogs.CaveSWTDialog;
|
|||
|
||||
public class GenerateCaseDlg extends CaveSWTDialog {
|
||||
|
||||
/** Extension for HDF files. */
|
||||
private static final String hdfExt = ".h5";
|
||||
|
||||
private final IUFStatusHandler statusHandler = UFStatus
|
||||
.getHandler(GenerateCaseDlg.class);
|
||||
|
||||
/** Extension for HDF files. */
|
||||
private static final String hdfExt = ".h5";
|
||||
|
||||
/** Use to display the current state of the case generation. */
|
||||
private Label stateLbl;
|
||||
|
||||
|
@ -121,7 +134,7 @@ public class GenerateCaseDlg extends CaveSWTDialog {
|
|||
/** End time for the case. */
|
||||
private final Calendar endCal;
|
||||
|
||||
/** Data list for the case. */
|
||||
/** Data list for the case sorted by archive and category names. */
|
||||
private final DisplayData[] sourceDataList;
|
||||
|
||||
/** When true compress the case directory. */
|
||||
|
@ -372,24 +385,74 @@ public class GenerateCaseDlg extends CaveSWTDialog {
|
|||
}
|
||||
|
||||
/**
|
||||
* The performs the work of generating the case on a non-UI thread.
|
||||
* This performs the work of generating the case on a non-UI thread.
|
||||
*/
|
||||
private class GenerateJob extends Job {
|
||||
/** Parent flag to shutdown the job. */
|
||||
private final AtomicBoolean shutdown = new AtomicBoolean(false);
|
||||
|
||||
/** How long to wait before making another request for a plug-in lock. */
|
||||
private final long LOCK_RETRY_TIME = 2 * TimeUtil.MILLIS_PER_MINUTE;
|
||||
|
||||
/** Files/directories needing plug-in locks in order to copy. */
|
||||
private final Map<CopyInfo, Map<String, List<File>>> caseCopyMap = new HashMap<CopyInfo, Map<String, List<File>>>();
|
||||
|
||||
/** Timer to determine when to send another request for a plug-in lock. */
|
||||
private final ITimer retrytimer = TimeUtil.getTimer();
|
||||
|
||||
/** Timer to update current lock's last execute time. */
|
||||
private Timer updateTimer = null;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
public GenerateJob() {
|
||||
super("Generate Case");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected IStatus run(IProgressMonitor monitor) {
|
||||
if (monitor.isCanceled()) {
|
||||
return Status.OK_STATUS;
|
||||
/**
|
||||
* Add file to the caseCopyMap.
|
||||
*
|
||||
* @param copyInfo
|
||||
* @param plugin
|
||||
* @param file
|
||||
* @return
|
||||
*/
|
||||
private boolean putFile(CopyInfo copyInfo, String plugin, File file) {
|
||||
if (caseCopyMap.size() == 0) {
|
||||
retrytimer.start();
|
||||
}
|
||||
|
||||
Map<String, List<File>> pluginMap = caseCopyMap.get(copyInfo);
|
||||
|
||||
if (pluginMap == null) {
|
||||
pluginMap = new HashMap<String, List<File>>();
|
||||
caseCopyMap.put(copyInfo, pluginMap);
|
||||
}
|
||||
|
||||
List<File> files = pluginMap.get(plugin);
|
||||
|
||||
if (files == null) {
|
||||
files = new ArrayList<File>();
|
||||
pluginMap.put(plugin, files);
|
||||
}
|
||||
return files.add(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param copyInfo
|
||||
* @return true if locks needed to complete the copy.
|
||||
*/
|
||||
private boolean keepCaseCopy(CopyInfo copyInfo) {
|
||||
return caseCopyMap.get(copyInfo) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true when valid case directory.
|
||||
*/
|
||||
private boolean validateCaseDirectory() {
|
||||
setStateLbl("Creating: " + caseDir.getName(),
|
||||
caseDir.getAbsolutePath());
|
||||
ICaseCopy caseCopy = null;
|
||||
|
||||
String errorMessage = null;
|
||||
if (caseDir.exists()) {
|
||||
|
@ -401,38 +464,95 @@ public class GenerateCaseDlg extends CaveSWTDialog {
|
|||
if (errorMessage != null) {
|
||||
setStateLbl(errorMessage, caseDir.getAbsolutePath());
|
||||
setProgressBar(100, SWT.ERROR);
|
||||
return Status.OK_STATUS;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (shutdown.get()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
*
|
||||
* @see org.eclipse.core.runtime.jobs.Job#run(org.eclipse.core.runtime.
|
||||
* IProgressMonitor)
|
||||
*/
|
||||
@Override
|
||||
protected IStatus run(IProgressMonitor monitor) {
|
||||
if (monitor.isCanceled()) {
|
||||
return Status.OK_STATUS;
|
||||
}
|
||||
|
||||
if (!validateCaseDirectory()) {
|
||||
return Status.OK_STATUS;
|
||||
}
|
||||
|
||||
ICaseCopy caseCopy = null;
|
||||
String currentArchive = null;
|
||||
String currentCategory = null;
|
||||
boolean updateDestDir = false;
|
||||
int rootDirLen = -1;
|
||||
File rootDir = null;
|
||||
String plugin = null;
|
||||
boolean allowCopy = true;
|
||||
CopyInfo copyInfo = null;
|
||||
|
||||
try {
|
||||
/*
|
||||
* The sourceDataList is sorted so all the displayDatas for a
|
||||
* given archive/category are grouped together in the loop.
|
||||
*/
|
||||
for (DisplayData displayData : sourceDataList) {
|
||||
if (shutdown.get()) {
|
||||
return Status.OK_STATUS;
|
||||
}
|
||||
|
||||
if (!displayData.getArchiveName().equals(currentArchive)) {
|
||||
updateDestDir = true;
|
||||
/*
|
||||
* The current display data is for a different
|
||||
* archive/category then the previous one.
|
||||
*/
|
||||
if (!displayData.getArchiveName().equals(currentArchive)
|
||||
|| !displayData.getCategoryName().equals(
|
||||
currentCategory)) {
|
||||
|
||||
// Finish up previous archive/category.
|
||||
if (caseCopy != null) {
|
||||
if (allowCopy) {
|
||||
releaseLock(plugin);
|
||||
}
|
||||
|
||||
/*
|
||||
* The copyInfo needs locks in order to finish.
|
||||
* Force creation of a new caseCopy for the new
|
||||
* category.
|
||||
*/
|
||||
if (keepCaseCopy(copyInfo)) {
|
||||
caseCopy = null;
|
||||
copyInfo = null;
|
||||
} else {
|
||||
caseCopy.finishCase();
|
||||
}
|
||||
plugin = null;
|
||||
}
|
||||
|
||||
// Set up for new category.
|
||||
currentArchive = displayData.getArchiveName();
|
||||
currentCategory = displayData.getCategoryName();
|
||||
} else if (!displayData.getCategoryName().equals(
|
||||
currentCategory)) {
|
||||
updateDestDir = true;
|
||||
currentCategory = displayData.getCategoryName();
|
||||
}
|
||||
rootDir = new File(displayData.getRootDir());
|
||||
rootDirLen = displayData.getRootDir().length();
|
||||
allowCopy = true;
|
||||
|
||||
if (updateDestDir) {
|
||||
updateDestDir = false;
|
||||
if (caseCopy != null) {
|
||||
caseCopy.finishCase();
|
||||
} else {
|
||||
setStateLbl(currentArchive + " | " + currentCategory,
|
||||
caseDir.getAbsolutePath() + "\n"
|
||||
+ currentArchive + "\n"
|
||||
+ currentCategory);
|
||||
|
||||
/*
|
||||
* When caseCopy is not null it is safe to reuse it for
|
||||
* the new category.
|
||||
*/
|
||||
if (caseCopy == null) {
|
||||
if (!doCompress) {
|
||||
caseCopy = new CopyMove();
|
||||
} else if (doMultiFiles) {
|
||||
|
@ -440,33 +560,83 @@ public class GenerateCaseDlg extends CaveSWTDialog {
|
|||
} else {
|
||||
caseCopy = new CompressCopy();
|
||||
}
|
||||
copyInfo = new CopyInfo(caseCopy, currentArchive,
|
||||
currentCategory, caseDir);
|
||||
}
|
||||
|
||||
caseCopy.startCase(caseDir, displayData, shutdown);
|
||||
setStateLbl(currentArchive + " | " + currentCategory,
|
||||
caseDir.getAbsolutePath() + "\n"
|
||||
+ currentArchive + "\n"
|
||||
+ currentCategory);
|
||||
}
|
||||
|
||||
List<File> files = archiveManager.getDisplayFiles(
|
||||
displayData, startCal, endCal);
|
||||
|
||||
/*
|
||||
* Check all files/directories in the displayData and
|
||||
* attempt a recursive copy.
|
||||
*/
|
||||
for (File source : files) {
|
||||
if (shutdown.get()) {
|
||||
return Status.OK_STATUS;
|
||||
}
|
||||
|
||||
caseCopy.copy(source);
|
||||
String dirName = source.getAbsolutePath()
|
||||
.substring(rootDirLen).split(File.separator)[0];
|
||||
String newPlugin = (new File(rootDir, dirName))
|
||||
.getAbsolutePath();
|
||||
|
||||
// Have new plugin.
|
||||
if (!newPlugin.equals(plugin)) {
|
||||
// Release the current lock.
|
||||
if (allowCopy && (plugin != null)) {
|
||||
releaseLock(plugin);
|
||||
}
|
||||
allowCopy = requestLock(newPlugin);
|
||||
plugin = newPlugin;
|
||||
}
|
||||
|
||||
if (allowCopy) {
|
||||
// Have lock safe to perform recursive copy.
|
||||
caseCopy.copy(source);
|
||||
} else {
|
||||
// No lock add to Map of files needing locks.
|
||||
putFile(copyInfo, plugin, source);
|
||||
}
|
||||
} // End of files loop
|
||||
|
||||
/*
|
||||
* The copy may have taken some time see if any pending
|
||||
* copies can be completed.
|
||||
*/
|
||||
if (retrytimer.getElapsedTime() >= LOCK_RETRY_TIME) {
|
||||
retryCasesCopy();
|
||||
}
|
||||
}
|
||||
if (caseCopy != null) {
|
||||
caseCopy.finishCase();
|
||||
} // End of sourceDataList loop
|
||||
|
||||
// Finish up the loop's last plugin.
|
||||
if (plugin != null) {
|
||||
if (allowCopy) {
|
||||
releaseLock(plugin);
|
||||
}
|
||||
|
||||
// Finish last case
|
||||
if (!keepCaseCopy(copyInfo)) {
|
||||
caseCopy.finishCase();
|
||||
}
|
||||
plugin = null;
|
||||
}
|
||||
caseCopy = null;
|
||||
|
||||
// Finish pending copies needing locks.
|
||||
waitForLocks();
|
||||
|
||||
if (shutdown.get()) {
|
||||
return Status.OK_STATUS;
|
||||
}
|
||||
|
||||
setStateLbl("Created: " + caseName, caseDir.getAbsolutePath());
|
||||
setProgressBar(100, SWT.NORMAL);
|
||||
|
||||
} catch (CaseCreateException e) {
|
||||
} catch (Exception e) {
|
||||
statusHandler.handle(Priority.PROBLEM, e.getLocalizedMessage(),
|
||||
e);
|
||||
setStateLbl(
|
||||
|
@ -474,20 +644,220 @@ public class GenerateCaseDlg extends CaveSWTDialog {
|
|||
caseDir.getAbsolutePath() + "\n"
|
||||
+ e.getLocalizedMessage());
|
||||
setProgressBar(100, SWT.ERROR);
|
||||
shutdown.set(true);
|
||||
} finally {
|
||||
// shutdown the time.
|
||||
if (updateTimer != null) {
|
||||
updateTimer.cancel();
|
||||
updateTimer = null;
|
||||
}
|
||||
|
||||
// Release resources of active case copy.
|
||||
if (caseCopy != null) {
|
||||
try {
|
||||
caseCopy.finishCase();
|
||||
} catch (CaseCreateException ex) {
|
||||
// Ignore
|
||||
if (!keepCaseCopy(copyInfo)) {
|
||||
try {
|
||||
// Allow the caseCopy to clean its resources.
|
||||
caseCopy.finishCase();
|
||||
} catch (CaseCreateException ex) {
|
||||
// Ignore
|
||||
}
|
||||
caseCopy = null;
|
||||
}
|
||||
caseCopy = null;
|
||||
}
|
||||
|
||||
// Release current lock.
|
||||
if (allowCopy && (plugin != null)) {
|
||||
releaseLock(plugin);
|
||||
}
|
||||
|
||||
// Release resources of any pending case copy.
|
||||
if (caseCopyMap.size() > 0) {
|
||||
for (CopyInfo cpi : caseCopyMap.keySet()) {
|
||||
try {
|
||||
cpi.caseCopy.finishCase();
|
||||
} catch (CaseCreateException ex) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
caseCopyMap.clear();
|
||||
}
|
||||
}
|
||||
|
||||
return Status.OK_STATUS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish copying all files needing locks.
|
||||
*
|
||||
* @throws CaseCreateException
|
||||
* @throws InterruptedException
|
||||
*/
|
||||
private void waitForLocks() throws CaseCreateException,
|
||||
InterruptedException {
|
||||
int retryCount = 0;
|
||||
boolean updateStatus = true;
|
||||
while (caseCopyMap.size() > 0) {
|
||||
if (updateStatus) {
|
||||
++retryCount;
|
||||
StringBuilder tooltip = new StringBuilder();
|
||||
tooltip.append("Waiting to finish ").append(
|
||||
caseCopyMap.size());
|
||||
if (caseCopyMap.size() == 1) {
|
||||
tooltip.append(" category.");
|
||||
} else {
|
||||
tooltip.append(" categories.");
|
||||
}
|
||||
tooltip.append("\nAttempt: ").append(retryCount);
|
||||
setStateLbl("Waiting for locks", tooltip.toString());
|
||||
updateStatus = false;
|
||||
}
|
||||
|
||||
synchronized (this) {
|
||||
wait(TimeUtil.MILLIS_PER_SECOND / 2L);
|
||||
}
|
||||
|
||||
if (shutdown.get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (retrytimer.getElapsedTime() >= LOCK_RETRY_TIME) {
|
||||
retryCasesCopy();
|
||||
updateStatus = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to copy files still waiting on plug-in locks.
|
||||
*
|
||||
* @throws CaseCreateException
|
||||
*/
|
||||
private void retryCasesCopy() throws CaseCreateException {
|
||||
if (shutdown.get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (caseCopyMap.size() > 0) {
|
||||
retrytimer.stop();
|
||||
retrytimer.reset();
|
||||
String lockedPlugin = null;
|
||||
try {
|
||||
Iterator<CopyInfo> copyInfoIter = caseCopyMap.keySet()
|
||||
.iterator();
|
||||
while (copyInfoIter.hasNext()) {
|
||||
CopyInfo copyInfo = copyInfoIter.next();
|
||||
setStateLbl(copyInfo.archive + " | "
|
||||
+ copyInfo.category,
|
||||
copyInfo.caseDir.getAbsolutePath() + "\n"
|
||||
+ copyInfo.archive + "\n"
|
||||
+ copyInfo.category);
|
||||
|
||||
Map<String, List<File>> pluginMap = caseCopyMap
|
||||
.get(copyInfo);
|
||||
Iterator<String> pluginIter = pluginMap.keySet()
|
||||
.iterator();
|
||||
while (pluginIter.hasNext()) {
|
||||
String plugin = pluginIter.next();
|
||||
if (shutdown.get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestLock(plugin)) {
|
||||
lockedPlugin = plugin;
|
||||
for (File source : pluginMap.get(plugin)) {
|
||||
copyInfo.caseCopy.copy(source);
|
||||
if (shutdown.get()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
releaseLock(plugin);
|
||||
lockedPlugin = null;
|
||||
pluginIter.remove();
|
||||
}
|
||||
}
|
||||
if (pluginMap.size() == 0) {
|
||||
copyInfo.caseCopy.finishCase();
|
||||
copyInfoIter.remove();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (lockedPlugin != null) {
|
||||
releaseLock(lockedPlugin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (caseCopyMap.size() > 0) {
|
||||
retrytimer.start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a lock for the plug-in.
|
||||
*
|
||||
* @param details
|
||||
* @return true when lock obtained otherwise false
|
||||
*/
|
||||
private boolean requestLock(String details) {
|
||||
SharedLockRequest request = new SharedLockRequest(
|
||||
ArchiveConstants.CLUSTER_NAME, details,
|
||||
RequestType.READER_LOCK);
|
||||
|
||||
try {
|
||||
Object o = ThriftClient.sendRequest(request);
|
||||
if (o instanceof SharedLockResponse) {
|
||||
SharedLockResponse response = (SharedLockResponse) o;
|
||||
if (response.isSucessful()) {
|
||||
if (updateTimer == null) {
|
||||
updateTimer = new Timer(
|
||||
"Case Creation update timer", true);
|
||||
}
|
||||
TimerTask timerTask = new LockUpdateTask(details);
|
||||
updateTimer.schedule(timerTask,
|
||||
TimeUtil.MILLIS_PER_MINUTE,
|
||||
TimeUtil.MILLIS_PER_MINUTE);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (VizException e) {
|
||||
statusHandler.handle(Priority.PROBLEM, e.getLocalizedMessage(),
|
||||
e);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release previously obtained lock for the details.
|
||||
*
|
||||
* @param details
|
||||
* @return true when lock released otherwise false.
|
||||
*/
|
||||
private boolean releaseLock(String details) {
|
||||
SharedLockRequest request = new SharedLockRequest(
|
||||
ArchiveConstants.CLUSTER_NAME, details,
|
||||
RequestType.READER_UNLOCK);
|
||||
try {
|
||||
if (updateTimer != null) {
|
||||
updateTimer.cancel();
|
||||
updateTimer = null;
|
||||
}
|
||||
Object o = ThriftClient.sendRequest(request);
|
||||
if (o instanceof SharedLockResponse) {
|
||||
SharedLockResponse response = (SharedLockResponse) o;
|
||||
if (response.isSucessful()) {
|
||||
details = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (VizException e) {
|
||||
statusHandler.handle(Priority.PROBLEM, e.getLocalizedMessage(),
|
||||
e);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
*
|
||||
|
@ -543,7 +913,7 @@ public class GenerateCaseDlg extends CaveSWTDialog {
|
|||
copyFile(new File(source, file),
|
||||
new File(destination, file));
|
||||
}
|
||||
} else {
|
||||
} else if (source.exists()) {
|
||||
// DR 2270 bump HDF files up a directory.
|
||||
if (destination.getName().endsWith(hdfExt)) {
|
||||
destination = new File(destination.getParentFile()
|
||||
|
@ -661,7 +1031,7 @@ public class GenerateCaseDlg extends CaveSWTDialog {
|
|||
tarDirFile.add(file);
|
||||
addTarFiles(file.listFiles());
|
||||
}
|
||||
} else {
|
||||
} else if (file.exists()) {
|
||||
// DR 2270 bump HDF files up a directory.
|
||||
if (name.endsWith(hdfExt)) {
|
||||
File destination = new File(file.getParentFile()
|
||||
|
@ -1091,4 +1461,46 @@ public class GenerateCaseDlg extends CaveSWTDialog {
|
|||
// }
|
||||
|
||||
}
|
||||
|
||||
/** Task to update the lock plugin's last execute time. */
|
||||
private final class LockUpdateTask extends TimerTask {
|
||||
/** The locked cluster task's details. */
|
||||
private final String details;
|
||||
|
||||
public LockUpdateTask(String details) {
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
SharedLockRequest request = new SharedLockRequest(
|
||||
ArchiveConstants.CLUSTER_NAME, details,
|
||||
RequestType.READER_UPDATE_TIME);
|
||||
try {
|
||||
ThriftClient.sendRequest(request);
|
||||
} catch (VizException e) {
|
||||
statusHandler.handle(Priority.PROBLEM, e.getLocalizedMessage(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Information needed to update status when retrying a copy. */
|
||||
private static class CopyInfo {
|
||||
protected final ICaseCopy caseCopy;
|
||||
|
||||
protected final String archive;
|
||||
|
||||
protected final String category;
|
||||
|
||||
protected final File caseDir;
|
||||
|
||||
public CopyInfo(ICaseCopy caseCopy, String archive, String category,
|
||||
File caseDir) {
|
||||
this.caseCopy = caseCopy;
|
||||
this.archive = archive;
|
||||
this.category = category;
|
||||
this.caseDir = caseDir;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,6 +34,7 @@ import com.raytheon.uf.common.localization.IPathManager;
|
|||
* ------------ ---------- ----------- --------------------------
|
||||
* Jul 23, 2013 #2221 rferrel Initial creation
|
||||
* Aug 26, 2013 #2225 rferrel Added tar extension.
|
||||
* Apr 11, 2014 #2862 rferrel Added cluster name.
|
||||
*
|
||||
* </pre>
|
||||
*
|
||||
|
@ -42,6 +43,8 @@ import com.raytheon.uf.common.localization.IPathManager;
|
|||
*/
|
||||
|
||||
public class ArchiveConstants {
|
||||
/** The value for the cluster tasks' name column. */
|
||||
public static final String CLUSTER_NAME = "Archive Shared Lock";
|
||||
|
||||
/** Pattern to find slashes in a string. */
|
||||
private final static Pattern slashPattern = Pattern.compile("[/\\\\]+");
|
||||
|
|
|
@ -0,0 +1,135 @@
|
|||
/**
|
||||
* This software was developed and / or modified by Raytheon Company,
|
||||
* pursuant to Contract DG133W-05-CQ-1067 with the US Government.
|
||||
*
|
||||
* U.S. EXPORT CONTROLLED TECHNICAL DATA
|
||||
* This software product contains export-restricted data whose
|
||||
* export/transfer/disclosure is restricted by U.S. law. Dissemination
|
||||
* to non-U.S. persons whether in the United States or abroad requires
|
||||
* an export license or other authorization.
|
||||
*
|
||||
* Contractor Name: Raytheon Company
|
||||
* Contractor Address: 6825 Pine Street, Suite 340
|
||||
* Mail Stop B8
|
||||
* Omaha, NE 68106
|
||||
* 402.291.0100
|
||||
*
|
||||
* See the AWIPS II Master Rights File ("Master Rights File.pdf") for
|
||||
* further licensing information.
|
||||
**/
|
||||
package com.raytheon.uf.common.dataquery.requests;
|
||||
|
||||
import com.raytheon.uf.common.serialization.annotations.DynamicSerialize;
|
||||
import com.raytheon.uf.common.serialization.annotations.DynamicSerializeElement;
|
||||
import com.raytheon.uf.common.serialization.comm.IServerRequest;
|
||||
|
||||
/**
|
||||
* The request class to coordinate with the shared locks in the
|
||||
* awips.custer_task table.
|
||||
*
|
||||
* <pre>
|
||||
*
|
||||
* SOFTWARE HISTORY
|
||||
*
|
||||
* Date Ticket# Engineer Description
|
||||
* ------------ ---------- ----------- --------------------------
|
||||
* Apr 1, 2014 2862 rferrel Initial creation
|
||||
*
|
||||
* </pre>
|
||||
*
|
||||
* @author rferrel
|
||||
* @version 1.0
|
||||
*/
|
||||
|
||||
@DynamicSerialize
|
||||
public class SharedLockRequest implements IServerRequest {
|
||||
/** The types of requests. */
|
||||
public static enum RequestType {
|
||||
READER_LOCK, READER_UNLOCK, READER_UPDATE_TIME, WRITER_LOCK, WRITER_UNLOCK, WRITER_UPDATE_TIME
|
||||
}
|
||||
|
||||
/** The name column entry. */
|
||||
@DynamicSerializeElement
|
||||
private String name;
|
||||
|
||||
/** The details column entry. */
|
||||
@DynamicSerializeElement
|
||||
private String details;
|
||||
|
||||
/* The desired request. */
|
||||
@DynamicSerializeElement
|
||||
private RequestType requestType;
|
||||
|
||||
/**
|
||||
* Default constructor should only be used for serialization.
|
||||
*/
|
||||
public SharedLockRequest() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Desired constructor.
|
||||
*
|
||||
* @param details
|
||||
* @param requestType
|
||||
*/
|
||||
public SharedLockRequest(String name, String details,
|
||||
RequestType requestType) {
|
||||
setName(name);
|
||||
setDetails(details);
|
||||
setRequestType(requestType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Getter.
|
||||
*
|
||||
* @return requestType
|
||||
*/
|
||||
public RequestType getRequestType() {
|
||||
return requestType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter should only be used for serialization.
|
||||
*
|
||||
* @param requestType
|
||||
*/
|
||||
public void setRequestType(RequestType requestType) {
|
||||
this.requestType = requestType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Getter.
|
||||
*
|
||||
* @return name
|
||||
*/
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter.
|
||||
*
|
||||
* @param name
|
||||
*/
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Getter.
|
||||
*
|
||||
* @return details
|
||||
*/
|
||||
public String getDetails() {
|
||||
return details;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter should only be used for serialization.
|
||||
*
|
||||
* @param details
|
||||
*/
|
||||
public void setDetails(String details) {
|
||||
this.details = details;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* This software was developed and / or modified by Raytheon Company,
|
||||
* pursuant to Contract DG133W-05-CQ-1067 with the US Government.
|
||||
*
|
||||
* U.S. EXPORT CONTROLLED TECHNICAL DATA
|
||||
* This software product contains export-restricted data whose
|
||||
* export/transfer/disclosure is restricted by U.S. law. Dissemination
|
||||
* to non-U.S. persons whether in the United States or abroad requires
|
||||
* an export license or other authorization.
|
||||
*
|
||||
* Contractor Name: Raytheon Company
|
||||
* Contractor Address: 6825 Pine Street, Suite 340
|
||||
* Mail Stop B8
|
||||
* Omaha, NE 68106
|
||||
* 402.291.0100
|
||||
*
|
||||
* See the AWIPS II Master Rights File ("Master Rights File.pdf") for
|
||||
* further licensing information.
|
||||
**/
|
||||
package com.raytheon.uf.common.dataquery.responses;
|
||||
|
||||
import com.raytheon.uf.common.serialization.annotations.DynamicSerialize;
|
||||
import com.raytheon.uf.common.serialization.annotations.DynamicSerializeElement;
|
||||
|
||||
/**
|
||||
* Response from the shared lock request handler.
|
||||
*
|
||||
* <pre>
|
||||
*
|
||||
* SOFTWARE HISTORY
|
||||
*
|
||||
* Date Ticket# Engineer Description
|
||||
* ------------ ---------- ----------- --------------------------
|
||||
* Apr 8, 2014 2862 rferrel Initial creation
|
||||
*
|
||||
* </pre>
|
||||
*
|
||||
* @author rferrel
|
||||
* @version 1.0
|
||||
*/
|
||||
|
||||
@DynamicSerialize
|
||||
public class SharedLockResponse {
|
||||
/** true when request was successful. */
|
||||
@DynamicSerializeElement
|
||||
private boolean sucessful;
|
||||
|
||||
/** Any error message from the handler. */
|
||||
@DynamicSerializeElement
|
||||
private String errorMessage;
|
||||
|
||||
public boolean isSucessful() {
|
||||
return sucessful;
|
||||
}
|
||||
|
||||
public void setSucessful(boolean sucessful) {
|
||||
this.sucessful = sucessful;
|
||||
}
|
||||
|
||||
public String getErrorMessage() {
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
public void setErrorMessage(String errorMessage) {
|
||||
this.errorMessage = errorMessage;
|
||||
}
|
||||
}
|
|
@ -30,6 +30,7 @@ import java.util.TimeZone;
|
|||
import java.util.Timer;
|
||||
import java.util.TimerTask;
|
||||
|
||||
import com.raytheon.uf.common.archive.config.ArchiveConstants;
|
||||
import com.raytheon.uf.common.dataplugin.PluginDataObject;
|
||||
import com.raytheon.uf.common.dataplugin.PluginException;
|
||||
import com.raytheon.uf.common.dataplugin.PluginProperties;
|
||||
|
@ -127,8 +128,8 @@ public class DatabaseArchiver implements IPluginArchiver {
|
|||
@Override
|
||||
public void run() {
|
||||
long currentTime = System.currentTimeMillis();
|
||||
ClusterLockUtils.updateLockTime(SharedLockHandler.name, details,
|
||||
currentTime);
|
||||
ClusterLockUtils.updateLockTime(ArchiveConstants.CLUSTER_NAME,
|
||||
details, currentTime);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -170,8 +171,8 @@ public class DatabaseArchiver implements IPluginArchiver {
|
|||
*/
|
||||
private ClusterTask getWriteLock(String details) {
|
||||
SharedLockHandler lockHandler = new SharedLockHandler(LockType.WRITER);
|
||||
ClusterTask ct = ClusterLockUtils.lock(SharedLockHandler.name, details,
|
||||
lockHandler, false);
|
||||
ClusterTask ct = ClusterLockUtils.lock(ArchiveConstants.CLUSTER_NAME,
|
||||
details, lockHandler, false);
|
||||
if (LockState.SUCCESSFUL.equals(ct.getLockState())) {
|
||||
if (statusHandler.isPriorityEnabled(Priority.INFO)) {
|
||||
statusHandler.handle(Priority.INFO, String.format(
|
||||
|
|
|
@ -34,6 +34,7 @@ import org.apache.commons.io.filefilter.IOFileFilter;
|
|||
|
||||
import com.raytheon.uf.common.archive.config.ArchiveConfig;
|
||||
import com.raytheon.uf.common.archive.config.ArchiveConfigManager;
|
||||
import com.raytheon.uf.common.archive.config.ArchiveConstants;
|
||||
import com.raytheon.uf.common.archive.config.CategoryConfig;
|
||||
import com.raytheon.uf.common.archive.config.CategoryFileDateHelper;
|
||||
import com.raytheon.uf.common.archive.config.DataSetStatus;
|
||||
|
@ -264,8 +265,8 @@ public class ArchivePurgeManager {
|
|||
*/
|
||||
private ClusterTask getWriteLock(String details) {
|
||||
SharedLockHandler lockHandler = new SharedLockHandler(LockType.WRITER);
|
||||
ClusterTask ct = ClusterLockUtils.lock(SharedLockHandler.name, details,
|
||||
lockHandler, false);
|
||||
ClusterTask ct = ClusterLockUtils.lock(ArchiveConstants.CLUSTER_NAME,
|
||||
details, lockHandler, false);
|
||||
if (ct.getLockState().equals(LockState.SUCCESSFUL)) {
|
||||
if (statusHandler.isPriorityEnabled(Priority.INFO)) {
|
||||
statusHandler.handle(Priority.INFO, String.format(
|
||||
|
|
|
@ -64,8 +64,10 @@ public class ArchiveAdminPrivilegedRequestHandler extends
|
|||
@Override
|
||||
public ArchiveAdminAuthRequest handleRequest(ArchiveAdminAuthRequest request)
|
||||
throws Exception {
|
||||
// If it reaches this point in the code, then the user is authorized, so
|
||||
// just return the request object with authorized set to true
|
||||
/*
|
||||
* If it reaches this point in the code, then the user is authorized, so
|
||||
* just return the request object with authorized set to true.
|
||||
*/
|
||||
request.setAuthorized(true);
|
||||
return request;
|
||||
}
|
||||
|
@ -85,14 +87,13 @@ public class ArchiveAdminPrivilegedRequestHandler extends
|
|||
AuthManager manager = AuthManagerFactory.getInstance().getManager();
|
||||
IRoleStorage roleStorage = manager.getRoleStorage();
|
||||
|
||||
boolean authorized = roleStorage.isAuthorized((request).getRoleId(),
|
||||
user.uniqueId().toString(), APPLICATION);
|
||||
boolean authorized = roleStorage.isAuthorized(request.getRoleId(), user
|
||||
.uniqueId().toString(), APPLICATION);
|
||||
|
||||
if (authorized) {
|
||||
return new AuthorizationResponse(authorized);
|
||||
} else {
|
||||
return new AuthorizationResponse(
|
||||
(request).getNotAuthorizedMessage());
|
||||
return new AuthorizationResponse(request.getNotAuthorizedMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
<beans xmlns="http://www.springframework.org/schema/beans"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.1.xsd">
|
||||
|
||||
<!-- Instantiate the handler class for RemoteScriptList Handler -->
|
||||
<bean id="SharedLockRequestHandler"
|
||||
class="com.raytheon.uf.edex.database.handlers.SharedLockRequestHandler"/>
|
||||
|
||||
<!-- Register the handler class with the RemoteScriptListRequest Register. -->
|
||||
<bean id="sharedLockRequest" factory-bean="handlerRegistry" factory-method="register">
|
||||
<constructor-arg value="com.raytheon.uf.common.dataquery.requests.SharedLockRequest"/>
|
||||
<constructor-arg ref="SharedLockRequestHandler"/>
|
||||
</bean>
|
||||
</beans>
|
||||
|
|
@ -56,9 +56,6 @@ public final class SharedLockHandler extends CurrentTimeClusterLockHandler {
|
|||
WRITER
|
||||
};
|
||||
|
||||
/** The value for the cluster tasks' name column. */
|
||||
public static final String name = "Shared Lock";
|
||||
|
||||
/**
|
||||
* Common override time out. Clients need to rely on this value in order to
|
||||
* update last execution time to maintain the lock.
|
||||
|
|
|
@ -0,0 +1,239 @@
|
|||
/**
|
||||
* This software was developed and / or modified by Raytheon Company,
|
||||
* pursuant to Contract DG133W-05-CQ-1067 with the US Government.
|
||||
*
|
||||
* U.S. EXPORT CONTROLLED TECHNICAL DATA
|
||||
* This software product contains export-restricted data whose
|
||||
* export/transfer/disclosure is restricted by U.S. law. Dissemination
|
||||
* to non-U.S. persons whether in the United States or abroad requires
|
||||
* an export license or other authorization.
|
||||
*
|
||||
* Contractor Name: Raytheon Company
|
||||
* Contractor Address: 6825 Pine Street, Suite 340
|
||||
* Mail Stop B8
|
||||
* Omaha, NE 68106
|
||||
* 402.291.0100
|
||||
*
|
||||
* See the AWIPS II Master Rights File ("Master Rights File.pdf") for
|
||||
* further licensing information.
|
||||
**/
|
||||
package com.raytheon.uf.edex.database.handlers;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.raytheon.uf.common.dataquery.requests.SharedLockRequest;
|
||||
import com.raytheon.uf.common.dataquery.requests.SharedLockRequest.RequestType;
|
||||
import com.raytheon.uf.common.dataquery.responses.SharedLockResponse;
|
||||
import com.raytheon.uf.common.serialization.comm.IRequestHandler;
|
||||
import com.raytheon.uf.common.status.IUFStatusHandler;
|
||||
import com.raytheon.uf.common.status.UFStatus;
|
||||
import com.raytheon.uf.common.status.UFStatus.Priority;
|
||||
import com.raytheon.uf.edex.database.cluster.ClusterLockUtils;
|
||||
import com.raytheon.uf.edex.database.cluster.ClusterLockUtils.LockState;
|
||||
import com.raytheon.uf.edex.database.cluster.ClusterTask;
|
||||
import com.raytheon.uf.edex.database.cluster.handler.SharedLockHandler;
|
||||
import com.raytheon.uf.edex.database.cluster.handler.SharedLockHandler.LockType;
|
||||
|
||||
/**
|
||||
* This is the handler class for a shared lock request. It coordinates with the
|
||||
* shared lock handler to perform the desired request.
|
||||
*
|
||||
* <pre>
|
||||
*
|
||||
* SOFTWARE HISTORY
|
||||
*
|
||||
* Date Ticket# Engineer Description
|
||||
* ------------ ---------- ----------- --------------------------
|
||||
* Apr 2, 2014 2862 rferrel Initial creation
|
||||
*
|
||||
* </pre>
|
||||
*
|
||||
* @author rferrel
|
||||
* @version 1.0
|
||||
*/
|
||||
|
||||
public class SharedLockRequestHandler implements
|
||||
IRequestHandler<SharedLockRequest> {
|
||||
|
||||
private final IUFStatusHandler statusHander = UFStatus
|
||||
.getHandler(SharedLockRequestHandler.class);
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
*
|
||||
* @see
|
||||
* com.raytheon.uf.common.serialization.comm.IRequestHandler#handleRequest
|
||||
* (com.raytheon.uf.common.serialization.comm.IServerRequest)
|
||||
*/
|
||||
@Override
|
||||
public SharedLockResponse handleRequest(SharedLockRequest request)
|
||||
throws Exception {
|
||||
|
||||
SharedLockResponse response = new SharedLockResponse();
|
||||
String name = request.getName();
|
||||
String details = request.getDetails();
|
||||
RequestType type = request.getRequestType();
|
||||
response.setSucessful(false);
|
||||
|
||||
try {
|
||||
switch (type) {
|
||||
case READER_LOCK:
|
||||
if (lock(name, details, LockType.READER)) {
|
||||
response.setSucessful(true);
|
||||
} else {
|
||||
response.setSucessful(false);
|
||||
response.setErrorMessage(String.format(
|
||||
"Unable to obtain %s lock.", LockType.READER));
|
||||
}
|
||||
break;
|
||||
case READER_UNLOCK:
|
||||
if (unlock(name, details, LockType.READER)) {
|
||||
response.setSucessful(true);
|
||||
} else {
|
||||
response.setSucessful(false);
|
||||
response.setErrorMessage(String.format(
|
||||
"Unable to unlock %s.", LockType.READER));
|
||||
}
|
||||
break;
|
||||
case READER_UPDATE_TIME:
|
||||
if (updateTime(name, details, LockType.READER)) {
|
||||
response.setSucessful(true);
|
||||
} else {
|
||||
response.setSucessful(false);
|
||||
response.setErrorMessage(String.format(
|
||||
"Unable to update %s last exection time.",
|
||||
LockType.READER));
|
||||
}
|
||||
break;
|
||||
case WRITER_LOCK:
|
||||
if (lock(name, details, LockType.WRITER)) {
|
||||
response.setSucessful(true);
|
||||
} else {
|
||||
response.setSucessful(false);
|
||||
response.setErrorMessage(String.format(
|
||||
"Unable to obtain %s lock.", LockType.WRITER));
|
||||
}
|
||||
break;
|
||||
case WRITER_UNLOCK:
|
||||
if (unlock(name, details, LockType.WRITER)) {
|
||||
response.setSucessful(true);
|
||||
} else {
|
||||
response.setSucessful(false);
|
||||
response.setErrorMessage(String.format(
|
||||
"Unable to unlock %s.", LockType.WRITER));
|
||||
}
|
||||
break;
|
||||
case WRITER_UPDATE_TIME:
|
||||
if (updateTime(name, details, LockType.WRITER)) {
|
||||
response.setSucessful(false);
|
||||
response.setErrorMessage(String.format(
|
||||
"Unable to update %s last execution time.",
|
||||
LockType.WRITER));
|
||||
}
|
||||
break;
|
||||
default:
|
||||
String message = "Unimplemented request type: " + type;
|
||||
statusHander.error(message);
|
||||
response.setErrorMessage(message);
|
||||
response.setSucessful(false);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
response.setSucessful(false);
|
||||
String message = String.format(
|
||||
"Request type %s for details %s failed %s", type, details,
|
||||
ex.getMessage());
|
||||
response.setErrorMessage(message);
|
||||
if (statusHander.isPriorityEnabled(Priority.PROBLEM)) {
|
||||
statusHander.handle(Priority.PROBLEM, message, ex);
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request details lock of the desired lock type.
|
||||
*
|
||||
* @param details
|
||||
* @param lockType
|
||||
* @return true when obtaining lock is successful
|
||||
*/
|
||||
private boolean lock(String name, String details, LockType lockType) {
|
||||
SharedLockHandler lockHandler = new SharedLockHandler(lockType);
|
||||
ClusterTask ct = ClusterLockUtils.lock(name, details, lockHandler,
|
||||
false);
|
||||
return LockState.SUCCESSFUL.equals(ct.getLockState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Release lock for given details. The unlock request is only attempted when
|
||||
* the details' lock type matches and the count is a positive number.
|
||||
*
|
||||
* @param details
|
||||
* @param lockType
|
||||
* @return true when successful
|
||||
*/
|
||||
private boolean unlock(String name, String details, LockType lockType) {
|
||||
ClusterTask ct = findCluster(name, details, lockType);
|
||||
if (ct != null) {
|
||||
SharedLockHandler handler = (SharedLockHandler) ct.getLockHandler();
|
||||
if (handler.getLockCount() > 0) {
|
||||
return ClusterLockUtils.unlock(ct, false);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the details' cluster task with latest extrainfo and set up its
|
||||
* handler. The found cluster task is only returned if its lock type matches
|
||||
* the requested type and it is in the run state.
|
||||
*
|
||||
* @param details
|
||||
* - Whose cluster task to find
|
||||
* @param lockType
|
||||
* - Expected lock type for the cluster
|
||||
* @return ct when found, matches lockType, and is running else null.
|
||||
*/
|
||||
private ClusterTask findCluster(String name, String details,
|
||||
LockType lockType) {
|
||||
ClusterTask ct = null;
|
||||
List<ClusterTask> cts = ClusterLockUtils.getLocks(name);
|
||||
|
||||
if ((cts != null) && (cts.size() > 0)) {
|
||||
for (ClusterTask tmpCt : cts) {
|
||||
if (details.equals(tmpCt.getId().getDetails())) {
|
||||
if (tmpCt.isRunning()) {
|
||||
SharedLockHandler handler = new SharedLockHandler(
|
||||
lockType);
|
||||
handler.parseExtraInfoString(tmpCt.getExtraInfo());
|
||||
if (handler.locksMatch()
|
||||
&& (handler.getLockCount() > 0)) {
|
||||
ct = tmpCt;
|
||||
ct.setLockHandler(handler);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ct;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the last execution time for the detail's lock.
|
||||
*
|
||||
* @param details
|
||||
* @param lockType
|
||||
* @return true when update successful
|
||||
*/
|
||||
private boolean updateTime(String name, String details, LockType lockType) {
|
||||
ClusterTask ct = findCluster(name, details, lockType);
|
||||
if (ct != null) {
|
||||
if (ClusterLockUtils.updateLockTime(ct.getId().getName(), ct
|
||||
.getId().getDetails(), System.currentTimeMillis())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue